Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"trigger.dev": minor
|
||||
"@trigger.dev/core": minor
|
||||
---
|
||||
|
||||
feat(cli): deterministic image builds for deployments
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
The new `triggeredVia` field is now populated in deployments via the CLI.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
fix(dev): stop max listeners exceeded warning messages when running more than 10 runs concurrently
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"trigger.dev": minor
|
||||
---
|
||||
|
||||
feat(cli): enable zstd compression for deployment images
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Upgrade @modelcontextprotocol/sdk to 1.24.3
|
||||
@@ -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,6 @@
|
||||
---
|
||||
description: how to create and apply database migrations
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues
|
||||
globs:
|
||||
- "**/*.ts"
|
||||
---
|
||||
|
||||
# OpenTelemetry Metrics Guidelines
|
||||
|
||||
When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**.
|
||||
|
||||
## What is Cardinality?
|
||||
|
||||
Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend.
|
||||
|
||||
## Rules
|
||||
|
||||
### DO use low-cardinality attributes:
|
||||
- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW)
|
||||
- **Booleans**: `hasFailures`, `streaming`, `success`
|
||||
- **Bounded error codes**: A finite, controlled set of error types
|
||||
- **Shard IDs**: When sharding is bounded (e.g., 0-15)
|
||||
|
||||
### DO NOT use high-cardinality attributes:
|
||||
- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId`
|
||||
- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount`
|
||||
- **Timestamps**: `createdAt`, `startTime`
|
||||
- **Free-form strings**: `errorMessage`, `taskName`, `queueName`
|
||||
|
||||
## Example
|
||||
|
||||
```typescript
|
||||
// BAD - High cardinality
|
||||
this.counter.add(1, {
|
||||
envId: options.environmentId, // UUID - unbounded
|
||||
itemCount: options.runCount, // Integer - unbounded
|
||||
});
|
||||
|
||||
// GOOD - Low cardinality
|
||||
this.counter.add(1, {
|
||||
environment_type: options.environmentType, // Enum - 4 values
|
||||
streaming: true, // Boolean - 2 values
|
||||
});
|
||||
```
|
||||
|
||||
## Prometheus Metric Naming
|
||||
|
||||
When metrics are exported via OTLP to Prometheus, the exporter automatically adds unit suffixes to metric names:
|
||||
|
||||
| OTel Metric Name | Unit | Prometheus Name |
|
||||
|------------------|------|-----------------|
|
||||
| `my_duration_ms` | `ms` | `my_duration_ms_milliseconds` |
|
||||
| `my_counter` | counter | `my_counter_total` |
|
||||
| `items_inserted` | counter | `items_inserted_inserts_total` |
|
||||
| `batch_size` | histogram | `batch_size_items_bucket` |
|
||||
|
||||
Keep this in mind when writing Grafana dashboards or Prometheus queries—the metric names in Prometheus will differ from the names defined in code.
|
||||
|
||||
## Reference
|
||||
|
||||
See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes.
|
||||
|
||||
High cardinality metrics can cause:
|
||||
- Memory bloat in metrics backends (Axiom, Prometheus, etc.)
|
||||
- Slow queries and dashboard timeouts
|
||||
- Increased costs (many backends charge per time series)
|
||||
- Potential data loss or crashes at scale
|
||||
+7
-1
@@ -85,4 +85,10 @@ POSTHOG_PROJECT_KEY=
|
||||
# These control the server-side internal telemetry
|
||||
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
|
||||
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
|
||||
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
|
||||
|
||||
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
|
||||
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
|
||||
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
claude_args: |
|
||||
--model claude-opus-4-5-20251101
|
||||
--allowedTools "Bash(pnpm:*),Bash(turbo:*),Bash(git:*),Bash(gh:*),Bash(npx:*),Bash(docker:*),Edit,MultiEdit,Read,Write,Glob,Grep,LS,Task"
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 🚀 Publish Trigger.dev Docker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_tag:
|
||||
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -72,6 +72,17 @@ jobs:
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
echo "Pre-pulling Docker images with authenticated session..."
|
||||
docker pull postgres:14
|
||||
docker pull clickhouse/clickhouse-server:25.4-alpine
|
||||
docker pull redis:7-alpine
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
docker pull electricsql/electric:1.2.4
|
||||
echo "Image pre-pull complete"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
+4
-2
@@ -61,5 +61,7 @@ apps/**/public/build
|
||||
/packages/core/src/package.json
|
||||
/packages/trigger-sdk/src/package.json
|
||||
/packages/python/src/package.json
|
||||
.claude
|
||||
.mcp.log
|
||||
**/.claude/settings.local.json
|
||||
.mcp.log
|
||||
.mcp.json
|
||||
.cursor/debug.log
|
||||
@@ -0,0 +1,294 @@
|
||||
# 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.
|
||||
|
||||
## 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`
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Build and deploy fully‑managed AI agents and workflows
|
||||
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
|
||||
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
## Creating and applying migrations
|
||||
|
||||
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
|
||||
|
||||
Edit the `schema.prisma` file to add or modify the schema.
|
||||
|
||||
Create a new migration file but don't apply it yet:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
|
||||
```
|
||||
|
||||
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
All the following lines should be removed:
|
||||
|
||||
```sql
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "public"."_completedWaitpoints_AB_unique";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
|
||||
```
|
||||
|
||||
Leaving only this:
|
||||
|
||||
```sql
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
|
||||
```
|
||||
|
||||
After editing the migration file, apply the migration:
|
||||
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallDown({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 6L9.75926 12.1142C9.36016 12.5798 8.63984 12.5798 8.24074 12.1142L3 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ChevronExtraSmallUp({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 12L8.24074 5.8858C8.63984 5.42019 9.36016 5.42019 9.75926 5.8858L15 12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeMiterlimit="1.00244"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./primitives/Badge";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
|
||||
export function AlphaBadge({
|
||||
inline = false,
|
||||
className,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Alpha
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Alpha."
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlphaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<AlphaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import Spline from "@splinetool/react-spline";
|
||||
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
@@ -57,14 +56,7 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
|
||||
</motion.div>
|
||||
<TriggerRotatingLogo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to adjacent">
|
||||
<Shortcut name="Jump to next/previous run">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"spline-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
url?: string;
|
||||
"loading-anim-type"?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__splineLoader?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerRotatingLogo() {
|
||||
const [isSplineReady, setIsSplineReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Already registered from a previous render
|
||||
if (customElements.get("spline-viewer")) {
|
||||
setIsSplineReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Another mount already started loading - share the same promise
|
||||
if (window.__splineLoader) {
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// First mount: create script and shared loader promise
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
// Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys
|
||||
script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js";
|
||||
|
||||
window.__splineLoader = new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
});
|
||||
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Intentionally no cleanup: once the custom element is registered globally,
|
||||
// removing the script would break re-mounts while providing no benefit
|
||||
}, []);
|
||||
|
||||
if (!isSplineReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<spline-viewer
|
||||
loading-anim-type="spinner-small-light"
|
||||
url="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
|
||||
// Lazy load streamdown components to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
| { type: "result"; success: true; query: string }
|
||||
| { type: "result"; success: false; error: string };
|
||||
|
||||
export type AIQueryMode = "new" | "edit";
|
||||
|
||||
interface AIQueryInputProps {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
/** Set this to a prompt to auto-populate and immediately submit */
|
||||
autoSubmitPrompt?: string;
|
||||
/** Get the current query in the editor (used for edit mode) */
|
||||
getCurrentQuery?: () => string;
|
||||
}
|
||||
|
||||
export function AIQueryInput({
|
||||
onQueryGenerated,
|
||||
autoSubmitPrompt,
|
||||
getCurrentQuery,
|
||||
}: AIQueryInputProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AIQueryMode>("new");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastAutoSubmitRef = useRef<string | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
|
||||
|
||||
// Can only use edit mode if there's a current query
|
||||
const canEdit = Boolean(getCurrentQuery?.()?.trim());
|
||||
|
||||
// If mode is edit but there's no current query, switch to new
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && !canEdit) {
|
||||
setMode("new");
|
||||
}
|
||||
}, [mode, canEdit]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
|
||||
if (!queryPrompt.trim() || isLoading) return;
|
||||
const currentQuery = getCurrentQuery?.();
|
||||
if (submitMode === "edit" && !currentQuery?.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setThinking("");
|
||||
setError(null);
|
||||
setShowThinking(true);
|
||||
setLastResult(null);
|
||||
|
||||
// Abort any existing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("mode", submitMode);
|
||||
if (submitMode === "edit" && currentQuery) {
|
||||
formData.append("currentQuery", currentQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(resourcePath, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
setError(errorData.error || "Failed to generate query");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setError("No response stream");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete events from buffer
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data
|
||||
if (buffer.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Request was aborted, ignore
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setLastResult("error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, resourcePath, mode, getCurrentQuery]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
setThinking((prev) => prev + `\nValidating query...\n`);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
submitQuery(prompt);
|
||||
},
|
||||
[prompt, submitQuery]
|
||||
);
|
||||
|
||||
// Auto-submit when autoSubmitPrompt changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoSubmitPrompt &&
|
||||
autoSubmitPrompt.trim() &&
|
||||
autoSubmitPrompt !== lastAutoSubmitRef.current &&
|
||||
!isLoading
|
||||
) {
|
||||
lastAutoSubmitRef.current = autoSubmitPrompt;
|
||||
setPrompt(autoSubmitPrompt);
|
||||
submitQuery(autoSubmitPrompt);
|
||||
}
|
||||
}, [autoSubmitPrompt, isLoading, submitQuery]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-hide error after delay
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-[5px] bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "e.g. add a filter for failed runs, change the limit to 50"
|
||||
: "e.g. show me failed runs from the last 7 days"
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isLoading}
|
||||
rows={8}
|
||||
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-1.5"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing..." : "Generating..."}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim()}
|
||||
LeadingIcon={PlusIcon}
|
||||
iconSpacing="gap-1.5"
|
||||
onClick={() => {
|
||||
setMode("new");
|
||||
submitQuery(prompt, "new");
|
||||
}}
|
||||
>
|
||||
New query
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim() || !canEdit}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
className={cn(!canEdit && "opacity-50")}
|
||||
iconSpacing="gap-2"
|
||||
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
submitQuery(prompt, "edit");
|
||||
}}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking panel - stays visible after completion */}
|
||||
<AnimatePresence>
|
||||
{showThinking && thinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 0.3)",
|
||||
foreground: "rgba(99, 102, 241, 1)",
|
||||
}}
|
||||
className="size-3"
|
||||
/>
|
||||
) : lastResult === "success" ? (
|
||||
<div className="size-3 rounded-full bg-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<div className="size-3 rounded-full bg-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking..."
|
||||
: lastResult === "success"
|
||||
? "Query generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart, LineChart } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
|
||||
|
||||
export interface ChartConfiguration {
|
||||
chartType: ChartType;
|
||||
xAxisColumn: string | null;
|
||||
yAxisColumns: string[];
|
||||
groupByColumn: string | null;
|
||||
stacked: boolean;
|
||||
sortByColumn: string | null;
|
||||
sortDirection: SortDirection;
|
||||
aggregation: AggregationType;
|
||||
}
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
xAxisColumn: null,
|
||||
yAxisColumns: [],
|
||||
groupByColumn: null,
|
||||
stacked: false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
onChange: (config: ChartConfiguration) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Type detection helpers
|
||||
function isNumericType(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("Int") ||
|
||||
type.startsWith("UInt") ||
|
||||
type.startsWith("Float") ||
|
||||
type.startsWith("Decimal") ||
|
||||
type.startsWith("Nullable(Int") ||
|
||||
type.startsWith("Nullable(UInt") ||
|
||||
type.startsWith("Nullable(Float") ||
|
||||
type.startsWith("Nullable(Decimal")
|
||||
);
|
||||
}
|
||||
|
||||
function isDateTimeType(type: string): boolean {
|
||||
return (
|
||||
type === "DateTime" ||
|
||||
type === "DateTime64" ||
|
||||
type === "Date" ||
|
||||
type === "Date32" ||
|
||||
type.startsWith("DateTime64(") ||
|
||||
type.startsWith("Nullable(DateTime") ||
|
||||
type.startsWith("Nullable(Date")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringType(type: string): boolean {
|
||||
return (
|
||||
type === "String" ||
|
||||
type === "LowCardinality(String)" ||
|
||||
type === "Nullable(String)" ||
|
||||
type.startsWith("Enum") ||
|
||||
type.startsWith("FixedString")
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
|
||||
// Categorize columns by type
|
||||
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
|
||||
const numeric: OutputColumnMetadata[] = [];
|
||||
const dateTime: OutputColumnMetadata[] = [];
|
||||
const categorical: OutputColumnMetadata[] = [];
|
||||
|
||||
for (const col of columns) {
|
||||
if (isNumericType(col.type)) {
|
||||
numeric.push(col);
|
||||
}
|
||||
if (isDateTimeType(col.type)) {
|
||||
dateTime.push(col);
|
||||
}
|
||||
if (isStringType(col.type) || isDateTimeType(col.type)) {
|
||||
categorical.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
numericColumns: numeric,
|
||||
dateTimeColumns: dateTime,
|
||||
categoricalColumns: categorical,
|
||||
allColumns: columns,
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
// Create a stable key from column names and types to detect actual changes
|
||||
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
|
||||
|
||||
// Use refs to access current config/onChange without adding them as dependencies
|
||||
const configRef = useRef(config);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Auto-select defaults when columns change
|
||||
useEffect(() => {
|
||||
if (columns.length === 0) return;
|
||||
|
||||
const currentConfig = configRef.current;
|
||||
let needsUpdate = false;
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
|
||||
// Auto-select X-axis (prefer datetime, then first categorical)
|
||||
if (!currentConfig.xAxisColumn) {
|
||||
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
|
||||
if (defaultX) {
|
||||
updates.xAxisColumn = defaultX.name;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select Y-axis (first numeric column)
|
||||
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
|
||||
updates.yAxisColumns = [numericColumns[0].name];
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Determine the effective x-axis column (either existing or newly selected)
|
||||
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
|
||||
|
||||
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
|
||||
if (
|
||||
effectiveXAxis &&
|
||||
!currentConfig.sortByColumn &&
|
||||
dateTimeColumns.some((col) => col.name === effectiveXAxis)
|
||||
) {
|
||||
updates.sortByColumn = effectiveXAxis;
|
||||
updates.sortDirection = "asc";
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
onChangeRef.current({ ...currentConfig, ...updates });
|
||||
}
|
||||
// Only re-run when the actual column structure changes, not on every config change
|
||||
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
// X-axis options: prefer datetime and string columns at the top
|
||||
const xAxisOptions = useMemo(() => {
|
||||
const preferred = [
|
||||
...dateTimeColumns,
|
||||
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
|
||||
];
|
||||
const preferredNames = new Set(preferred.map((c) => c.name));
|
||||
const other = allColumns.filter((c) => !preferredNames.has(c.name));
|
||||
|
||||
const options: Array<{ value: string; label: string; type: string }> = [];
|
||||
|
||||
for (const col of preferred) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
for (const col of other) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [allColumns, dateTimeColumns, categoricalColumns]);
|
||||
|
||||
// Y-axis options: numeric columns only
|
||||
const yAxisOptions = useMemo(() => {
|
||||
return numericColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
}, [numericColumns]);
|
||||
|
||||
// Aggregation options
|
||||
const aggregationOptions = [
|
||||
{ value: "sum", label: "Sum" },
|
||||
{ value: "avg", label: "Average" },
|
||||
{ value: "count", label: "Count" },
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
// Group by options: categorical columns (excluding selected X axis)
|
||||
const groupByOptions = useMemo(() => {
|
||||
const options = categoricalColumns
|
||||
.filter((col) => col.name !== config.xAxisColumn)
|
||||
.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [categoricalColumns, config.xAxisColumn]);
|
||||
|
||||
// Sort by options: all columns
|
||||
const sortByOptions = useMemo(() => {
|
||||
const options = allColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [allColumns]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center p-4", className)}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Run a query to configure the chart
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 px-3 py-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex items-center gap-1">
|
||||
<ConfigField label="Type">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-r-none border-b pl-1 pr-2",
|
||||
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "bar" })}
|
||||
LeadingIcon={BarChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
|
||||
Bar
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-l-none border-b pl-1 pr-2",
|
||||
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "line" })}
|
||||
LeadingIcon={LineChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
|
||||
>
|
||||
Line
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
value={config.xAxisColumn ?? ""}
|
||||
setValue={(value) => {
|
||||
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
|
||||
// Auto-set sort to x-axis ASC if selecting a datetime column
|
||||
if (value) {
|
||||
const selectedCol = columns.find((c) => c.name === value);
|
||||
if (selectedCol && isDateTimeType(selectedCol.type)) {
|
||||
updates.sortByColumn = value;
|
||||
updates.sortDirection = "asc";
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={xAxisOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Y-Axis */}
|
||||
<ConfigField label="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>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Aggregation */}
|
||||
<ConfigField label="Aggregation">
|
||||
<Select
|
||||
value={config.aggregation}
|
||||
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
|
||||
variant="tertiary/small"
|
||||
items={aggregationOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Group By */}
|
||||
<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>
|
||||
</ConfigField>
|
||||
|
||||
{/* Stacked toggle (only when grouped) */}
|
||||
{config.groupByColumn && (
|
||||
<ConfigField label="">
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Stacked"
|
||||
checked={config.stacked}
|
||||
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{/* Order By */}
|
||||
<ConfigField label="Order by">
|
||||
<Select
|
||||
value={config.sortByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ sortByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={sortByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="">
|
||||
<SortDirectionToggle
|
||||
direction={config.sortDirection}
|
||||
onChange={(direction) => updateConfig({ sortDirection: direction })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{label && <span className="text-xs text-text-dimmed">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDirectionToggle({
|
||||
direction,
|
||||
onChange,
|
||||
}: {
|
||||
direction: SortDirection;
|
||||
onChange: (direction: SortDirection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("asc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "asc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Ascending"
|
||||
>
|
||||
Asc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "desc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Descending"
|
||||
>
|
||||
Desc
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
// Simplify type for display
|
||||
let displayType = type;
|
||||
if (type.startsWith("Nullable(")) {
|
||||
displayType = type.slice(9, -1) + "?";
|
||||
}
|
||||
if (type.startsWith("LowCardinality(")) {
|
||||
displayType = type.slice(15, -1);
|
||||
}
|
||||
|
||||
// Shorten long type names
|
||||
if (displayType.length > 12) {
|
||||
displayType = displayType.slice(0, 10) + "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded bg-charcoal-750 px-1 py-0.5 font-mono text-xxs text-text-dimmed">
|
||||
{displayType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ async function setup() {
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-sql.js");
|
||||
}
|
||||
setup();
|
||||
|
||||
@@ -470,6 +472,8 @@ function HighlightCode({
|
||||
import("prismjs/components/prism-json"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-typescript"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-sql.js"),
|
||||
]).then(() => setIsLoaded(true));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
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";
|
||||
|
||||
// Color palette for chart series
|
||||
const CHART_COLORS = [
|
||||
"#7655fd", // Primary purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
"#06b6d4", // Cyan
|
||||
"#ec4899", // Pink
|
||||
"#8b5cf6", // Violet
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
interface QueryResultsChartProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
data: Record<string, unknown>[];
|
||||
series: string[];
|
||||
/** Raw date values for determining formatting granularity */
|
||||
dateValues: Date[];
|
||||
/** Whether the x-axis is date-based (continuous time scale) */
|
||||
isDateBased: boolean;
|
||||
/** The data key to use for x-axis (column name or '__timestamp' for dates) */
|
||||
xDataKey: string;
|
||||
/** Min/max timestamps for domain when date-based */
|
||||
timeDomain: [number, number] | null;
|
||||
/** Pre-calculated tick values for the time axis */
|
||||
timeTicks: number[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time granularity levels for date formatting
|
||||
*/
|
||||
type TimeGranularity = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years";
|
||||
|
||||
/**
|
||||
* Determines the appropriate time granularity based on the date range
|
||||
*/
|
||||
function detectTimeGranularity(dates: Date[]): TimeGranularity {
|
||||
if (dates.length < 2) return "days";
|
||||
|
||||
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
|
||||
const minDate = sorted[0];
|
||||
const maxDate = sorted[sorted.length - 1];
|
||||
const rangeMs = maxDate.getTime() - minDate.getTime();
|
||||
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
// Choose granularity based on range
|
||||
if (rangeMs <= 5 * MINUTE) return "seconds"; // < 5 minutes → show seconds
|
||||
if (rangeMs <= 2 * HOUR) return "minutes"; // < 2 hours → show minutes
|
||||
if (rangeMs <= 2 * DAY) return "hours"; // < 2 days → show hours
|
||||
if (rangeMs <= 2 * WEEK) return "days"; // < 2 weeks → show days
|
||||
if (rangeMs <= 3 * MONTH) return "weeks"; // < 3 months → show weeks
|
||||
if (rangeMs <= 2 * YEAR) return "months"; // < 2 years → show months
|
||||
return "years"; // >= 2 years → show years
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for the X-axis based on the detected granularity
|
||||
*/
|
||||
function formatDateByGranularity(date: Date, granularity: TimeGranularity): string {
|
||||
switch (granularity) {
|
||||
case "seconds":
|
||||
// "10:30:45"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
case "minutes":
|
||||
// "10:30"
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
case "hours":
|
||||
// "Jan 15 10:00"
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})} ${date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}`;
|
||||
case "days":
|
||||
// "Jan 15"
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
case "weeks":
|
||||
// "Jan 15"
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
case "months":
|
||||
// "Jan 2024"
|
||||
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
|
||||
case "years":
|
||||
// "2024"
|
||||
return date.toLocaleDateString("en-US", { year: "numeric" });
|
||||
default:
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the most common interval between consecutive data points
|
||||
* This helps us understand the natural granularity of the data
|
||||
*/
|
||||
function detectDataInterval(timestamps: number[]): number {
|
||||
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
|
||||
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const gap = sorted[i] - sorted[i - 1];
|
||||
if (gap > 0) {
|
||||
gaps.push(gap);
|
||||
}
|
||||
}
|
||||
|
||||
if (gaps.length === 0) return 60 * 1000;
|
||||
|
||||
// Find the most common small gap (this is likely the data's natural interval)
|
||||
// We use the minimum gap as a heuristic for the data interval
|
||||
const minGap = Math.min(...gaps);
|
||||
|
||||
// Round to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Snap to common intervals
|
||||
if (minGap <= MINUTE) return MINUTE;
|
||||
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (minGap <= HOUR) return HOUR;
|
||||
if (minGap <= 2 * HOUR) return 2 * HOUR;
|
||||
if (minGap <= 4 * HOUR) return 4 * HOUR;
|
||||
if (minGap <= 6 * HOUR) return 6 * HOUR;
|
||||
if (minGap <= 12 * HOUR) return 12 * HOUR;
|
||||
if (minGap <= DAY) return DAY;
|
||||
|
||||
return minGap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in missing time slots with zero values
|
||||
* This ensures the chart shows gaps as zeros rather than connecting distant points
|
||||
*/
|
||||
function fillTimeGaps(
|
||||
data: Record<string, unknown>[],
|
||||
xDataKey: string,
|
||||
series: string[],
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
interval: number,
|
||||
granularity: TimeGranularity,
|
||||
aggregation: AggregationType,
|
||||
maxPoints = 1000
|
||||
): Record<string, unknown>[] {
|
||||
const range = maxTime - minTime;
|
||||
const estimatedPoints = Math.ceil(range / interval);
|
||||
|
||||
// If filling would create too many points, increase the interval to stay within limits
|
||||
let effectiveInterval = interval;
|
||||
if (estimatedPoints > maxPoints) {
|
||||
effectiveInterval = Math.ceil(range / maxPoints);
|
||||
// Round up to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
|
||||
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
|
||||
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
|
||||
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
|
||||
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
|
||||
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
|
||||
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
|
||||
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
|
||||
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
|
||||
else effectiveInterval = 24 * HOUR;
|
||||
}
|
||||
|
||||
// Create a map to collect values for each bucket (for aggregation)
|
||||
const bucketData = new Map<
|
||||
number,
|
||||
{ values: Record<string, number[]>; rawDate: Date; originalX: string }
|
||||
>();
|
||||
|
||||
for (const point of data) {
|
||||
const timestamp = point[xDataKey] as number;
|
||||
// Bucket to the nearest interval
|
||||
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
|
||||
|
||||
if (!bucketData.has(bucketedTime)) {
|
||||
bucketData.set(bucketedTime, {
|
||||
values: Object.fromEntries(series.map((s) => [s, []])),
|
||||
rawDate: new Date(bucketedTime),
|
||||
originalX: new Date(bucketedTime).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const bucket = bucketData.get(bucketedTime)!;
|
||||
for (const s of series) {
|
||||
const val = point[s] as number;
|
||||
if (typeof val === "number") {
|
||||
bucket.values[s].push(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all time slots and fill with zeros where missing
|
||||
const filledData: Record<string, unknown>[] = [];
|
||||
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
|
||||
|
||||
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
|
||||
const bucket = bucketData.get(t);
|
||||
if (bucket) {
|
||||
// Apply aggregation to collected values
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: bucket.rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: bucket.originalX,
|
||||
};
|
||||
for (const s of series) {
|
||||
point[s] = aggregateValues(bucket.values[s], aggregation);
|
||||
}
|
||||
filledData.push(point);
|
||||
} else {
|
||||
// Create a zero-filled data point
|
||||
const zeroPoint: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: new Date(t),
|
||||
__granularity: granularity,
|
||||
__originalX: new Date(t).toISOString(),
|
||||
};
|
||||
for (const s of series) {
|
||||
zeroPoint[s] = 0;
|
||||
}
|
||||
filledData.push(zeroPoint);
|
||||
}
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Nice" intervals for time axes - these create human-friendly tick marks
|
||||
*/
|
||||
const NICE_TIME_INTERVALS = [
|
||||
{ value: 1000, label: "1s" }, // 1 second
|
||||
{ value: 5 * 1000, label: "5s" }, // 5 seconds
|
||||
{ value: 10 * 1000, label: "10s" }, // 10 seconds
|
||||
{ value: 30 * 1000, label: "30s" }, // 30 seconds
|
||||
{ value: 60 * 1000, label: "1m" }, // 1 minute
|
||||
{ value: 5 * 60 * 1000, label: "5m" }, // 5 minutes
|
||||
{ value: 10 * 60 * 1000, label: "10m" }, // 10 minutes
|
||||
{ value: 15 * 60 * 1000, label: "15m" }, // 15 minutes
|
||||
{ value: 30 * 60 * 1000, label: "30m" }, // 30 minutes
|
||||
{ value: 60 * 60 * 1000, label: "1h" }, // 1 hour
|
||||
{ value: 2 * 60 * 60 * 1000, label: "2h" }, // 2 hours
|
||||
{ value: 3 * 60 * 60 * 1000, label: "3h" }, // 3 hours
|
||||
{ value: 4 * 60 * 60 * 1000, label: "4h" }, // 4 hours
|
||||
{ value: 6 * 60 * 60 * 1000, label: "6h" }, // 6 hours
|
||||
{ value: 12 * 60 * 60 * 1000, label: "12h" }, // 12 hours
|
||||
{ value: 24 * 60 * 60 * 1000, label: "1d" }, // 1 day
|
||||
{ value: 2 * 24 * 60 * 60 * 1000, label: "2d" }, // 2 days
|
||||
{ value: 7 * 24 * 60 * 60 * 1000, label: "1w" }, // 1 week
|
||||
{ value: 14 * 24 * 60 * 60 * 1000, label: "2w" }, // 2 weeks
|
||||
{ value: 30 * 24 * 60 * 60 * 1000, label: "1mo" }, // ~1 month
|
||||
{ value: 90 * 24 * 60 * 60 * 1000, label: "3mo" }, // ~3 months
|
||||
{ value: 180 * 24 * 60 * 60 * 1000, label: "6mo" }, // ~6 months
|
||||
{ value: 365 * 24 * 60 * 60 * 1000, label: "1y" }, // 1 year
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate evenly-spaced tick values for a time axis using "nice" intervals
|
||||
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
|
||||
*/
|
||||
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
|
||||
const range = maxTime - minTime;
|
||||
|
||||
if (range <= 0) {
|
||||
return [minTime];
|
||||
}
|
||||
|
||||
// Find the best "nice" interval that gives us a reasonable number of ticks
|
||||
// Target: between 4 and maxTicks ticks
|
||||
let chosenInterval = NICE_TIME_INTERVALS[NICE_TIME_INTERVALS.length - 1].value;
|
||||
|
||||
for (const { value: interval } of NICE_TIME_INTERVALS) {
|
||||
const tickCount = Math.ceil(range / interval);
|
||||
if (tickCount <= maxTicks && tickCount >= 2) {
|
||||
chosenInterval = interval;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Align the start tick to a nice boundary
|
||||
// For intervals >= 1 day, align to midnight
|
||||
// For intervals >= 1 hour, align to hour boundary
|
||||
// For intervals >= 1 minute, align to minute boundary
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
let alignTo: number;
|
||||
if (chosenInterval >= DAY) {
|
||||
// Align to midnight UTC (or we could use local midnight)
|
||||
alignTo = DAY;
|
||||
} else if (chosenInterval >= HOUR) {
|
||||
alignTo = chosenInterval; // Align to the interval itself for hours
|
||||
} else if (chosenInterval >= MINUTE) {
|
||||
alignTo = chosenInterval;
|
||||
} else {
|
||||
alignTo = chosenInterval;
|
||||
}
|
||||
|
||||
// Round down to the alignment boundary, then find first tick at or before minTime
|
||||
const startTick = Math.floor(minTime / alignTo) * alignTo;
|
||||
|
||||
// Generate ticks
|
||||
const ticks: number[] = [];
|
||||
for (let t = startTick; t <= maxTime + chosenInterval; t += chosenInterval) {
|
||||
if (t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1) {
|
||||
ticks.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have at least 2 ticks
|
||||
if (ticks.length < 2) {
|
||||
return [minTime, maxTime];
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for tooltips (always shows full precision)
|
||||
*/
|
||||
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
|
||||
// For shorter time ranges, include time
|
||||
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: granularity === "seconds" ? "2-digit" : undefined,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
// For longer ranges, just show date
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a value as a Date
|
||||
*/
|
||||
function tryParseDate(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
// First, try treating the number as milliseconds
|
||||
const dateAsMs = new Date(value);
|
||||
if (
|
||||
!isNaN(dateAsMs.getTime()) &&
|
||||
dateAsMs.getFullYear() >= 1970 &&
|
||||
dateAsMs.getFullYear() <= 2100
|
||||
) {
|
||||
return dateAsMs;
|
||||
}
|
||||
// If that fails, try treating the number as seconds (Unix timestamp)
|
||||
const dateAsSec = new Date(value * 1000);
|
||||
if (
|
||||
!isNaN(dateAsSec.getTime()) &&
|
||||
dateAsSec.getFullYear() >= 1970 &&
|
||||
dateAsSec.getFullYear() <= 2100
|
||||
) {
|
||||
return dateAsSec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw query results into chart-ready data
|
||||
*
|
||||
* When grouped:
|
||||
* - Pivots data so each unique group value becomes a separate series
|
||||
* - Each row in output has xAxis value + one key per group value
|
||||
*
|
||||
* When not grouped:
|
||||
* - Uses Y-axis columns directly as series
|
||||
*
|
||||
* For date-based x-axes:
|
||||
* - Uses numeric timestamps so the chart renders with a continuous time scale
|
||||
* - This ensures gaps in data are visually apparent
|
||||
*/
|
||||
function transformDataForChart(
|
||||
rows: Record<string, unknown>[],
|
||||
config: ChartConfiguration
|
||||
): TransformedData {
|
||||
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
|
||||
|
||||
if (!xAxisColumn || yAxisColumns.length === 0) {
|
||||
return {
|
||||
data: [],
|
||||
series: [],
|
||||
dateValues: [],
|
||||
isDateBased: false,
|
||||
xDataKey: xAxisColumn || "",
|
||||
timeDomain: null,
|
||||
timeTicks: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect date values for granularity detection
|
||||
const dateValues: Date[] = [];
|
||||
for (const row of rows) {
|
||||
const date = tryParseDate(row[xAxisColumn]);
|
||||
if (date) {
|
||||
dateValues.push(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if X-axis is date-based (most values should be parseable as dates)
|
||||
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
|
||||
|
||||
// For date-based axes, use a special key for the timestamp
|
||||
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
|
||||
|
||||
// Calculate time domain and ticks for date-based axes
|
||||
let timeDomain: [number, number] | null = null;
|
||||
let timeTicks: number[] | null = null;
|
||||
if (isDateBased && dateValues.length > 0) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
// Add a small padding (2% on each side) so points aren't at the very edge
|
||||
const padding = (maxTime - minTime) * 0.02;
|
||||
timeDomain = [minTime - padding, maxTime + padding];
|
||||
// Generate evenly-spaced ticks across the entire range using nice intervals
|
||||
timeTicks = generateTimeTicks(minTime, maxTime);
|
||||
}
|
||||
|
||||
// Helper to format X value for categorical axes (non-date)
|
||||
const formatX = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "N/A";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// No grouping: use Y columns directly as series
|
||||
// Group rows by X value first, then aggregate
|
||||
if (!groupByColumn) {
|
||||
// Group rows by X-axis value to handle duplicates
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ yValues: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, {
|
||||
yValues: Object.fromEntries(yAxisColumns.map((col) => [col, []])),
|
||||
rawDate,
|
||||
originalX: row[xAxisColumn],
|
||||
});
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
for (const yCol of yAxisColumns) {
|
||||
existing.yValues[yCol].push(toNumber(row[yCol]));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { yValues, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
__rawDate: rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: originalX,
|
||||
};
|
||||
for (const yCol of yAxisColumns) {
|
||||
point[yCol] = aggregateValues(yValues[yCol], aggregation);
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
yAxisColumns,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
}
|
||||
|
||||
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
}
|
||||
|
||||
// With grouping: pivot data so each group value becomes a series
|
||||
const yCol = yAxisColumns[0]; // Use first Y column when grouping
|
||||
const groupValues = new Set<string>();
|
||||
|
||||
// For date-based, key by timestamp; otherwise by formatted string
|
||||
// Collect all values for aggregation
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
const groupValue = String(row[groupByColumn] ?? "Unknown");
|
||||
const yValue = toNumber(row[yCol]);
|
||||
|
||||
groupValues.add(groupValue);
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
// Collect values for aggregation
|
||||
if (!existing.values[groupValue]) {
|
||||
existing.values[groupValue] = [];
|
||||
}
|
||||
existing.values[groupValue].push(yValue);
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
const series = Array.from(groupValues).sort();
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
__rawDate: rawDate,
|
||||
__granularity: granularity,
|
||||
__originalX: originalX,
|
||||
};
|
||||
for (const group of series) {
|
||||
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
series,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
);
|
||||
}
|
||||
|
||||
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function
|
||||
*/
|
||||
function aggregateValues(values: number[], aggregation: AggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort data array by a specified column
|
||||
*/
|
||||
function sortData(
|
||||
data: Record<string, unknown>[],
|
||||
sortByColumn: string | null,
|
||||
sortDirection: "asc" | "desc",
|
||||
xAxisColumn?: string | null
|
||||
): Record<string, unknown>[] {
|
||||
if (!sortByColumn) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = a[sortByColumn];
|
||||
const bVal = b[sortByColumn];
|
||||
|
||||
// Handle null/undefined
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortDirection === "asc" ? -1 : 1;
|
||||
if (bVal == null) return sortDirection === "asc" ? 1 : -1;
|
||||
|
||||
// Only use date comparison when sorting by the X-axis column
|
||||
if (sortByColumn === xAxisColumn) {
|
||||
const aDate = a.__rawDate as Date | null;
|
||||
const bDate = b.__rawDate as Date | null;
|
||||
if (aDate && bDate) {
|
||||
const diff = aDate.getTime() - bDate.getTime();
|
||||
return sortDirection === "asc" ? diff : -diff;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare as numbers if possible
|
||||
const aNum = typeof aVal === "number" ? aVal : parseFloat(String(aVal));
|
||||
const bNum = typeof bVal === "number" ? bVal : parseFloat(String(bVal));
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) {
|
||||
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
|
||||
}
|
||||
|
||||
// Fall back to string comparison
|
||||
const aStr = String(aVal);
|
||||
const bStr = String(bVal);
|
||||
const cmp = aStr.localeCompare(bStr);
|
||||
return sortDirection === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
yAxisColumns,
|
||||
chartType,
|
||||
groupByColumn,
|
||||
stacked,
|
||||
sortByColumn,
|
||||
sortDirection,
|
||||
} = config;
|
||||
|
||||
// Transform data for charting
|
||||
const {
|
||||
data: unsortedData,
|
||||
series,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
|
||||
|
||||
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
|
||||
const data = useMemo(() => {
|
||||
if (isDateBased) {
|
||||
// Always sort by timestamp for date-based axes
|
||||
return sortData(unsortedData, xDataKey, "asc", xDataKey);
|
||||
}
|
||||
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
|
||||
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
|
||||
|
||||
// Detect time granularity for the data
|
||||
const timeGranularity = useMemo(
|
||||
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
|
||||
[dateValues]
|
||||
);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
const xAxisTickFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: number) => {
|
||||
const date = new Date(value);
|
||||
return formatDateByGranularity(date, timeGranularity);
|
||||
};
|
||||
}, [isDateBased, timeGranularity]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
|
||||
// Build chart config for colors/labels
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
series.forEach((s, i) => {
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: getSeriesColor(i),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [series]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
return (label: string, payload: Array<{ payload?: Record<string, unknown> }>) => {
|
||||
// Try to get the raw date from the payload for better formatting
|
||||
const rawDate = payload[0]?.payload?.__rawDate as Date | null | undefined;
|
||||
const granularity = payload[0]?.payload?.__granularity as TimeGranularity | undefined;
|
||||
|
||||
if (rawDate && granularity) {
|
||||
return formatDateForTooltip(rawDate, granularity);
|
||||
}
|
||||
return label;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Validation
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (yAxisColumns.length === 0) {
|
||||
return <EmptyState message="Select a Y-axis column to display the chart" />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
data,
|
||||
margin: { top: 10, right: 10, left: 10, bottom: 10 },
|
||||
};
|
||||
|
||||
// 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
|
||||
? {
|
||||
dataKey: xDataKey,
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? ["auto", "auto"],
|
||||
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,
|
||||
}
|
||||
: {
|
||||
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,
|
||||
};
|
||||
|
||||
const yAxisProps = {
|
||||
fontSize: 12,
|
||||
tickLine: false,
|
||||
tickMargin: 8,
|
||||
axisLine: false,
|
||||
tick: { fill: "var(--color-text-dimmed)" },
|
||||
tickFormatter: yAxisFormatter,
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Y-axis value formatter based on the data range
|
||||
*/
|
||||
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
|
||||
// Find min and max values across all series
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
|
||||
for (const point of data) {
|
||||
for (const s of series) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
minVal = Math.min(minVal, val);
|
||||
maxVal = Math.max(maxVal, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const range = maxVal - minVal;
|
||||
|
||||
return (value: number): string => {
|
||||
// Use abbreviations for large numbers
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (Math.abs(value) >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
// Determine decimal places based on range
|
||||
if (range === 0 || !isFinite(range)) {
|
||||
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
|
||||
}
|
||||
|
||||
// For small ranges, show more precision
|
||||
if (range < 0.01) {
|
||||
return value.toFixed(4);
|
||||
}
|
||||
if (range < 0.1) {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
if (range < 10) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
if (range < 100) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
// For large ranges, no decimals
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
type ReactCodeMirrorProps,
|
||||
type UseCodeMirror,
|
||||
useCodeMirror,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
|
||||
import { createTSQLLinter } from "./tsql/tsqlLinter";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { format as formatSQL } from "sql-formatter";
|
||||
|
||||
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
/** Initial value for the editor */
|
||||
defaultValue?: string;
|
||||
/** Whether the editor is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Called when the editor content changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Called when the editor state updates */
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
/** Called when the editor loses focus */
|
||||
onBlur?: (code: string) => void;
|
||||
/** Schema for table/column autocompletion */
|
||||
schema?: TableSchema[];
|
||||
/** Show copy button */
|
||||
showCopyButton?: boolean;
|
||||
/** Show clear button */
|
||||
showClearButton?: boolean;
|
||||
/** Show format button */
|
||||
showFormatButton?: boolean;
|
||||
/** Enable linting (syntax checking) */
|
||||
linterEnabled?: boolean;
|
||||
/** Placeholder text when empty */
|
||||
placeholder?: string;
|
||||
/** Additional actions to show in the toolbar */
|
||||
additionalActions?: React.ReactNode;
|
||||
/** Minimum height of the editor */
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
|
||||
|
||||
const defaultProps: TSQLEditorDefaultProps = {
|
||||
readOnly: false,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
showCopyButton: true,
|
||||
showClearButton: false,
|
||||
showFormatButton: true,
|
||||
schema: [],
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup = false,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = false,
|
||||
showFormatButton = true,
|
||||
linterEnabled = true,
|
||||
schema = [],
|
||||
placeholder = "",
|
||||
additionalActions,
|
||||
minHeight = undefined,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Create extensions - memoize to avoid recreating on every render
|
||||
const extensions = useMemo(() => {
|
||||
const exts = getEditorSetup();
|
||||
|
||||
// Add SQL language support with StandardSQL dialect
|
||||
// This provides syntax highlighting
|
||||
exts.push(
|
||||
sql({
|
||||
dialect: StandardSQL,
|
||||
upperCaseKeywords: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Add custom TSQL completion
|
||||
if (schema && schema.length > 0) {
|
||||
exts.push(
|
||||
autocompletion({
|
||||
override: [createTSQLCompletion(schema)],
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add TSQL linter
|
||||
if (linterEnabled) {
|
||||
exts.push(lintGutter());
|
||||
exts.push(
|
||||
linter(createTSQLLinter({ schema }), {
|
||||
delay: 300, // Debounce linting for better performance
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
};
|
||||
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
// Update editor when defaultValue changes
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const format = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (!currentContent.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = autoFormatSQL(currentContent);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: formatted },
|
||||
});
|
||||
onChange?.(formatted);
|
||||
} catch {
|
||||
// If formatting fails (e.g., invalid SQL), silently ignore
|
||||
}
|
||||
}, [view, onChange]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", opts.className)}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
onBlur(view.state.doc.toString());
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
className="flex-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
format();
|
||||
}}
|
||||
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
return formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { memo, useState } from "react";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
CopyableTableCell,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
descriptionForTaskRunStatus,
|
||||
isRunFriendlyStatus,
|
||||
isTaskRunStatus,
|
||||
runStatusFromFriendlyTitle,
|
||||
TaskRunStatusCombo,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { QueueName } from "../runs/v3/QueueName";
|
||||
|
||||
const MAX_STRING_DISPLAY_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Truncate a string for display, adding ellipsis if it exceeds max length
|
||||
*/
|
||||
function truncateString(value: string, maxLength: number = MAX_STRING_DISPLAY_LENGTH): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.slice(0, maxLength) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any value to a string suitable for copying
|
||||
* Objects and arrays are JSON stringified, primitives use String()
|
||||
*/
|
||||
function valueToString(value: unknown): string {
|
||||
if (value === null) return "NULL";
|
||||
if (value === undefined) return "UNDEFINED";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ClickHouse type is a DateTime type
|
||||
*/
|
||||
function isDateTimeType(type: string): boolean {
|
||||
return (
|
||||
type === "DateTime" ||
|
||||
type === "DateTime64" ||
|
||||
type === "Date" ||
|
||||
type === "Date32" ||
|
||||
type.startsWith("Nullable(DateTime") ||
|
||||
type.startsWith("Nullable(Date")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ClickHouse type is a numeric type
|
||||
*/
|
||||
function isNumericType(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("Int") ||
|
||||
type.startsWith("UInt") ||
|
||||
type.startsWith("Float") ||
|
||||
type.startsWith("Nullable(Int") ||
|
||||
type.startsWith("Nullable(UInt") ||
|
||||
type.startsWith("Nullable(Float")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ClickHouse type is a boolean type
|
||||
*/
|
||||
function isBooleanType(type: string): boolean {
|
||||
return type === "Bool" || type === "Nullable(Bool)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that tracks hover state and passes it to CellValue
|
||||
* This optimizes rendering by only enabling tooltips when the cell is hovered
|
||||
*/
|
||||
function CellValueWrapper({
|
||||
value,
|
||||
column,
|
||||
prettyFormatting,
|
||||
}: {
|
||||
value: unknown;
|
||||
column: OutputColumnMetadata;
|
||||
prettyFormatting: boolean;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex-1"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<CellValue
|
||||
value={value}
|
||||
column={column}
|
||||
prettyFormatting={prettyFormatting}
|
||||
hovered={hovered}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a cell value based on its type and optional customRenderType
|
||||
*/
|
||||
function CellValue({
|
||||
value,
|
||||
column,
|
||||
prettyFormatting = true,
|
||||
hovered = false,
|
||||
}: {
|
||||
value: unknown;
|
||||
column: OutputColumnMetadata;
|
||||
prettyFormatting?: boolean;
|
||||
hovered?: boolean;
|
||||
}) {
|
||||
// Plain text mode - render everything as monospace text with truncation
|
||||
if (!prettyFormatting) {
|
||||
const plainValue = value === null ? "NULL" : String(value);
|
||||
const isTruncated = plainValue.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{plainValue}
|
||||
</pre>
|
||||
}
|
||||
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <pre className="font-mono text-xs">{plainValue}</pre>;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
return <pre className="text-text-dimmed">NULL</pre>;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return <pre className="text-text-dimmed">UNDEFINED</pre>;
|
||||
}
|
||||
|
||||
// First check customRenderType for special rendering
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
case "runId": {
|
||||
if (typeof value === "string") {
|
||||
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "runStatus": {
|
||||
// We have mapped the status to a friendly status so we need to map back to render the normal component
|
||||
const status = isTaskRunStatus(value)
|
||||
? value
|
||||
: isRunFriendlyStatus(value)
|
||||
? runStatusFromFriendlyTitle(value)
|
||||
: undefined;
|
||||
if (status) {
|
||||
if (hovered) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TaskRunStatusCombo status={status} />;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "duration":
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{formatDurationMilliseconds(value, { style: "short" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "durationSeconds":
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{formatDurationMilliseconds(value * 1000, { style: "short" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "cost":
|
||||
if (typeof value === "number") {
|
||||
// Assume cost values are in cents
|
||||
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "costInDollars":
|
||||
if (typeof value === "number") {
|
||||
// Value is already in dollars, no conversion needed
|
||||
return <span className="tabular-nums">{formatCurrencyAccurate(value)}</span>;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "machine": {
|
||||
const preset = MachinePresetName.safeParse(value);
|
||||
if (preset.success) {
|
||||
return <MachineLabelCombo preset={preset.data} />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "environmentType": {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"].includes(value)
|
||||
) {
|
||||
return (
|
||||
<EnvironmentLabel
|
||||
environment={{ type: value as "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "project": {
|
||||
if (typeof value === "string") {
|
||||
return <ProjectCellValue value={value} />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "environment": {
|
||||
if (typeof value === "string") {
|
||||
return <EnvironmentCellValue value={value} />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "queue": {
|
||||
if (typeof value === "string") {
|
||||
const type = value.startsWith("task/") ? "task" : "custom";
|
||||
return <QueueName type={type} name={value.replace("task/", "")} />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to rendering based on ClickHouse type
|
||||
const { type } = column;
|
||||
|
||||
// DateTime types
|
||||
if (isDateTimeType(type)) {
|
||||
if (typeof value === "string") {
|
||||
return <DateTimeAccurate date={value} showTooltip={hovered} />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
// JSON type
|
||||
if (type === "JSON") {
|
||||
const jsonString = JSON.stringify(value);
|
||||
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{jsonString}
|
||||
</pre>
|
||||
}
|
||||
button={
|
||||
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-xs text-text-dimmed">{jsonString}</span>;
|
||||
}
|
||||
|
||||
// Array types
|
||||
if (type.startsWith("Array")) {
|
||||
const arrayString = JSON.stringify(value);
|
||||
const isTruncated = arrayString.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{arrayString}
|
||||
</pre>
|
||||
}
|
||||
button={
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
{truncateString(arrayString)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-xs text-text-dimmed">{arrayString}</span>;
|
||||
}
|
||||
|
||||
// Boolean types
|
||||
if (isBooleanType(type)) {
|
||||
if (typeof value === "boolean") {
|
||||
return <span className="text-text-dimmed">{value ? "true" : "false"}</span>;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return <span className="text-text-dimmed">{value === 1 ? "true" : "false"}</span>;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
// Numeric types
|
||||
if (isNumericType(type)) {
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatNumber(value)}</span>;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
// Default to string rendering with truncation for long values
|
||||
const stringValue = String(value);
|
||||
const isTruncated = stringValue.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={
|
||||
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
|
||||
{stringValue}
|
||||
</pre>
|
||||
}
|
||||
button={<span>{truncateString(stringValue)}</span>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{stringValue}</span>;
|
||||
}
|
||||
|
||||
function ProjectCellValue({ value }: { value: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = organization.projects.find((p) => p.externalRef === value);
|
||||
|
||||
if (!project) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
return <TextLink to={v3ProjectPath(organization, project)}>{project.name}</TextLink>;
|
||||
}
|
||||
|
||||
function EnvironmentCellValue({ value }: { value: string }) {
|
||||
const project = useProject();
|
||||
const environment = project.environments.find((e) => e.slug === value);
|
||||
|
||||
if (!environment) {
|
||||
return <span>{value}</span>;
|
||||
}
|
||||
|
||||
return <EnvironmentLabel environment={environment} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a column should be right-aligned (numeric columns, duration, cost)
|
||||
*/
|
||||
function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
|
||||
// Check for custom render types that display numeric values
|
||||
if (
|
||||
column.customRenderType === "duration" ||
|
||||
column.customRenderType === "durationSeconds" ||
|
||||
column.customRenderType === "cost" ||
|
||||
column.customRenderType === "costInDollars"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isNumericType(column.type);
|
||||
}
|
||||
|
||||
export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
rows,
|
||||
columns,
|
||||
prettyFormatting = true,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
prettyFormatting?: boolean;
|
||||
}) {
|
||||
if (!columns.length) return null;
|
||||
|
||||
return (
|
||||
<Table fullWidth containerClassName="h-full overflow-y-auto border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHeaderCell
|
||||
key={col.name}
|
||||
alignment={isRightAlignedColumn(col) ? "right" : "left"}
|
||||
tooltip={col.description}
|
||||
>
|
||||
{col.name}
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length}>
|
||||
<Paragraph variant="extra-small" className="p-2 text-text-dimmed">
|
||||
No results
|
||||
</Paragraph>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{columns.map((col) => (
|
||||
<CopyableTableCell
|
||||
key={col.name}
|
||||
alignment={isRightAlignedColumn(col) ? "right" : "left"}
|
||||
value={valueToString(row[col.name])}
|
||||
>
|
||||
<CellValueWrapper
|
||||
value={row[col.name]}
|
||||
column={col}
|
||||
prettyFormatting={prettyFormatting}
|
||||
/>
|
||||
</CopyableTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
// TSQL CodeMirror support
|
||||
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
|
||||
|
||||
export { createTSQLCompletion } from "./tsqlCompletion";
|
||||
export { createTSQLLinter, isValidTSQLQuery, getTSQLError, type TSQLLinterConfig } from "./tsqlLinter";
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import type { TableSchema, ColumnSchema } from "@internal/tsql";
|
||||
import {
|
||||
TSQL_CLICKHOUSE_FUNCTIONS,
|
||||
TSQL_AGGREGATIONS,
|
||||
} from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* SQL keywords for autocomplete
|
||||
*/
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"AS",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"JOIN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"INNER",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"UNION",
|
||||
"INTERSECT",
|
||||
"EXCEPT",
|
||||
"ALL",
|
||||
"WITH",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"OVER",
|
||||
"PARTITION",
|
||||
"ROWS",
|
||||
"RANGE",
|
||||
"UNBOUNDED",
|
||||
"PRECEDING",
|
||||
"FOLLOWING",
|
||||
"CURRENT",
|
||||
"ROW",
|
||||
"NULLS",
|
||||
"FIRST",
|
||||
"LAST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create keyword completions from the SQL keywords list
|
||||
*/
|
||||
function createKeywordCompletions(): Completion[] {
|
||||
return SQL_KEYWORDS.map((keyword) => ({
|
||||
label: keyword,
|
||||
type: "keyword",
|
||||
boost: -1, // Keywords should have lower priority than schema items
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create function completions from TSQL function definitions
|
||||
*/
|
||||
function createFunctionCompletions(): Completion[] {
|
||||
const functions: Completion[] = [];
|
||||
|
||||
// Add regular functions
|
||||
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
|
||||
// Skip internal functions starting with _
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: argsHint,
|
||||
apply: `${name}()`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add aggregate functions with slightly higher boost
|
||||
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: `aggregate ${argsHint}`,
|
||||
apply: `${name}()`,
|
||||
boost: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create table completions from schema
|
||||
*/
|
||||
function createTableCompletions(schema: TableSchema[]): Completion[] {
|
||||
return schema.map((table) => ({
|
||||
label: table.name,
|
||||
type: "class", // Using "class" type for tables gives them a nice icon
|
||||
detail: table.description || "table",
|
||||
boost: 1, // Tables should have higher priority
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create column completions for a specific table
|
||||
*/
|
||||
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
|
||||
const columns: Completion[] = [];
|
||||
|
||||
for (const [name, column] of Object.entries(table.columns)) {
|
||||
columns.push({
|
||||
label: prefix ? `${prefix}.${name}` : name,
|
||||
type: "property", // Using "property" type for columns
|
||||
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
|
||||
boost: 2, // Columns should have highest priority
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table names/aliases from the current query context
|
||||
* This is a simplified parser that looks for FROM and JOIN clauses
|
||||
*/
|
||||
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
|
||||
const tableMap = new Map<string, TableSchema>();
|
||||
const tableNames = schema.map((t) => t.name);
|
||||
|
||||
// Simple regex to find table references in FROM and JOIN clauses
|
||||
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
|
||||
const tablePattern =
|
||||
/(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
|
||||
|
||||
let match;
|
||||
while ((match = tablePattern.exec(doc)) !== null) {
|
||||
const tableName = match[1];
|
||||
const alias = match[2] || tableName;
|
||||
|
||||
// Find the table schema if it exists
|
||||
const tableSchema = schema.find(
|
||||
(t) => t.name.toLowerCase() === tableName.toLowerCase()
|
||||
);
|
||||
|
||||
if (tableSchema) {
|
||||
tableMap.set(alias.toLowerCase(), tableSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return tableMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what context we're in based on cursor position
|
||||
*/
|
||||
type CompletionContextType =
|
||||
| "table" // After FROM or JOIN
|
||||
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
|
||||
| "alias" // After table_name.
|
||||
| "value" // After comparison operator (=, !=, IN, etc.)
|
||||
| "general"; // Anywhere else
|
||||
|
||||
/**
|
||||
* Result of context detection
|
||||
*/
|
||||
interface ContextResult {
|
||||
type: CompletionContextType;
|
||||
tablePrefix?: string;
|
||||
/** Column being compared (for value context) */
|
||||
columnName?: string;
|
||||
/** Table alias for the column (for value context) */
|
||||
columnTableAlias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column name from text before a comparison operator
|
||||
* Handles: "column =", "table.column =", "column IN", etc.
|
||||
*/
|
||||
function extractColumnBeforeOperator(textBefore: string): { columnName: string; tableAlias?: string } | null {
|
||||
// Match patterns like: column =, column !=, column IN, table.column =, etc.
|
||||
// We need to capture the column (and optional table prefix) before the operator
|
||||
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,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = textBefore.match(pattern);
|
||||
if (match) {
|
||||
if (match.length === 3) {
|
||||
// table.column pattern
|
||||
return { tableAlias: match[1], columnName: match[2] };
|
||||
} else {
|
||||
// just column pattern
|
||||
return { columnName: match[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function determineContext(
|
||||
doc: string,
|
||||
pos: number
|
||||
): ContextResult {
|
||||
// Get text before cursor
|
||||
const textBefore = doc.slice(0, pos);
|
||||
|
||||
// Check if we're in a value context (after comparison operator)
|
||||
// This should be checked before other contexts
|
||||
const columnInfo = extractColumnBeforeOperator(textBefore);
|
||||
if (columnInfo) {
|
||||
return {
|
||||
type: "value",
|
||||
columnName: columnInfo.columnName,
|
||||
columnTableAlias: columnInfo.tableAlias,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing after a dot (table.column)
|
||||
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
|
||||
if (dotMatch) {
|
||||
return { type: "alias", tablePrefix: dotMatch[1] };
|
||||
}
|
||||
|
||||
// Find the LAST significant keyword before cursor
|
||||
// We match all keywords and take the last one
|
||||
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = keywordPattern.exec(textBefore)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (lastMatch) {
|
||||
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
|
||||
|
||||
if (keyword === "FROM" || keyword === "JOIN") {
|
||||
return { type: "table" };
|
||||
}
|
||||
|
||||
if (
|
||||
keyword === "SELECT" ||
|
||||
keyword === "WHERE" ||
|
||||
keyword === "AND" ||
|
||||
keyword === "OR" ||
|
||||
keyword === "ORDER BY" ||
|
||||
keyword === "GROUP BY" ||
|
||||
keyword === "HAVING" ||
|
||||
keyword === "ON"
|
||||
) {
|
||||
return { type: "column" };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "general" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a column schema by name in the tables map
|
||||
*/
|
||||
function findColumnSchema(
|
||||
columnName: string,
|
||||
tableAlias: string | undefined,
|
||||
tables: Map<string, TableSchema>
|
||||
): ColumnSchema | null {
|
||||
if (tableAlias) {
|
||||
// Look in specific table
|
||||
const tableSchema = tables.get(tableAlias.toLowerCase());
|
||||
if (tableSchema) {
|
||||
return tableSchema.columns[columnName] || null;
|
||||
}
|
||||
} else {
|
||||
// Look in all tables
|
||||
for (const tableSchema of tables.values()) {
|
||||
const col = tableSchema.columns[columnName];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completions for enum values
|
||||
* Uses user-friendly values from valueMap when available, showing internal value as detail
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
|
||||
return columnSchema.allowedValues.map((value) => ({
|
||||
label: `'${value}'`,
|
||||
type: "enum",
|
||||
detail: columnSchema.description || "allowed value",
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL-aware autocompletion source
|
||||
*
|
||||
* @param schema - Array of table schemas to use for completions
|
||||
* @returns A CodeMirror completion source function
|
||||
*/
|
||||
export function createTSQLCompletion(
|
||||
schema: TableSchema[]
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
// Pre-compute static completions
|
||||
const keywordCompletions = createKeywordCompletions();
|
||||
const functionCompletions = createFunctionCompletions();
|
||||
const tableCompletions = createTableCompletions(schema);
|
||||
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
// Get the word being typed - include single quotes for value completion
|
||||
const word = context.matchBefore(/[\w.']+/);
|
||||
|
||||
// Don't show completions if no word is being typed and not explicitly triggered
|
||||
if (!word && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const from = word ? word.from : context.pos;
|
||||
const doc = context.state.doc.toString();
|
||||
const queryContext = determineContext(doc, context.pos);
|
||||
|
||||
let options: Completion[] = [];
|
||||
|
||||
switch (queryContext.type) {
|
||||
case "table":
|
||||
// After FROM or JOIN, show only tables
|
||||
options = tableCompletions;
|
||||
break;
|
||||
|
||||
case "alias":
|
||||
// After table., show columns for that table
|
||||
if (queryContext.tablePrefix) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
options = createColumnCompletions(tableSchema);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "value":
|
||||
// After comparison operator, show enum values if available
|
||||
if (queryContext.columnName) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const columnSchema = findColumnSchema(
|
||||
queryContext.columnName,
|
||||
queryContext.columnTableAlias,
|
||||
tables
|
||||
);
|
||||
|
||||
if (columnSchema) {
|
||||
options = createEnumValueCompletions(columnSchema);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "column":
|
||||
// After SELECT, WHERE, etc., show columns, functions, and some keywords
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
|
||||
// Add columns from all tables in the query
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
// If multiple tables, prefix with alias
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
|
||||
// Also add functions and relevant keywords
|
||||
options.push(...functionCompletions);
|
||||
options.push(
|
||||
...keywordCompletions.filter((k) =>
|
||||
["AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS", "NULL", "AS", "CASE", "WHEN", "THEN", "ELSE", "END"].includes(
|
||||
k.label as string
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "general":
|
||||
default:
|
||||
// Show everything
|
||||
options = [
|
||||
...tableCompletions,
|
||||
...functionCompletions,
|
||||
...keywordCompletions,
|
||||
];
|
||||
|
||||
// Also add columns from tables in query
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
options,
|
||||
validFor: /^[\w.']*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
|
||||
|
||||
describe("tsqlLinter", () => {
|
||||
describe("isValidTSQLQuery", () => {
|
||||
it("should return true for empty queries", () => {
|
||||
expect(isValidTSQLQuery("")).toBe(true);
|
||||
expect(isValidTSQLQuery(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for valid SELECT queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with ORDER BY", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with LIMIT", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with JOINs", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isValidTSQLQuery(
|
||||
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid syntax", () => {
|
||||
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for incomplete queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTSQLError", () => {
|
||||
it("should return null for empty queries", () => {
|
||||
expect(getTSQLError("")).toBeNull();
|
||||
expect(getTSQLError(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for valid queries", () => {
|
||||
expect(getTSQLError("SELECT * FROM users")).toBeNull();
|
||||
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error message for invalid queries", () => {
|
||||
const error = getTSQLError("SELEC * FROM users");
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe("string");
|
||||
});
|
||||
|
||||
it("should include position information in error", () => {
|
||||
const error = getTSQLError("SELECT * FORM users");
|
||||
expect(error).not.toBeNull();
|
||||
// Error message should contain line/column info
|
||||
expect(error).toContain("line");
|
||||
});
|
||||
|
||||
it("should handle missing FROM clause", () => {
|
||||
const error = getTSQLError("SELECT * WHERE id = 1");
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* Configuration for the TSQL linter
|
||||
*/
|
||||
export interface TSQLLinterConfig {
|
||||
/** Optional schema for validating table/column names */
|
||||
schema?: TableSchema[];
|
||||
/** Delay in milliseconds before running the linter (debouncing) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line and column from a TSQL error message
|
||||
* Error format: "Syntax error at line X:Y: message"
|
||||
*/
|
||||
function parseErrorPosition(message: string): { line: number; column: number } | null {
|
||||
const match = message.match(/at line (\d+):(\d+)/);
|
||||
if (match) {
|
||||
return {
|
||||
line: parseInt(match[1], 10),
|
||||
column: parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert line/column to a document position
|
||||
*/
|
||||
function positionToOffset(
|
||||
doc: string,
|
||||
line: number,
|
||||
column: number
|
||||
): number {
|
||||
const lines = doc.split("\n");
|
||||
|
||||
// line is 1-indexed
|
||||
let offset = 0;
|
||||
for (let i = 0; i < line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
return offset + column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a word/token at the given position
|
||||
*/
|
||||
function findTokenEnd(doc: string, start: number): number {
|
||||
let end = start;
|
||||
|
||||
// Scan forward until we hit whitespace or end of string
|
||||
while (end < doc.length && /\S/.test(doc[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// If we didn't move, include at least one character
|
||||
if (end === start) {
|
||||
end = Math.min(start + 1, doc.length);
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL linter function for CodeMirror
|
||||
*
|
||||
* This linter uses the TSQL ANTLR parser to detect syntax errors
|
||||
* and optionally validates against a schema.
|
||||
*
|
||||
* @param config - Linter configuration
|
||||
* @returns A linter function for use with CodeMirror's linter extension
|
||||
*/
|
||||
export function createTSQLLinter(
|
||||
config: TSQLLinterConfig = {}
|
||||
): (view: EditorView) => Diagnostic[] {
|
||||
const { schema = [] } = config;
|
||||
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// Return no errors for empty content
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
// Try to parse the query
|
||||
const ast = parseTSQLSelect(content);
|
||||
|
||||
// If parsing succeeds and we have a schema, run schema validation
|
||||
if (schema.length > 0) {
|
||||
const validationResult = validateQuery(ast, schema);
|
||||
|
||||
for (const issue of validationResult.issues) {
|
||||
// Map validation severity to CodeMirror diagnostic severity
|
||||
const severity: "error" | "warning" | "info" =
|
||||
issue.severity === "error"
|
||||
? "error"
|
||||
: issue.severity === "warning"
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity,
|
||||
message: issue.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const position = parseErrorPosition(error.message);
|
||||
|
||||
let from: number;
|
||||
let to: number;
|
||||
|
||||
if (position) {
|
||||
from = positionToOffset(content, position.line, position.column);
|
||||
to = findTokenEnd(content, from);
|
||||
} else {
|
||||
// If we can't parse the position, highlight the whole query
|
||||
from = 0;
|
||||
to = content.length;
|
||||
}
|
||||
|
||||
// Clean up the error message
|
||||
let message = error.message;
|
||||
// Remove the "Syntax error at line X:Y: " prefix if present
|
||||
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
|
||||
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "error",
|
||||
message: message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof QueryError) {
|
||||
// Schema validation errors don't have position info,
|
||||
// so highlight the whole query
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "warning",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
// Unknown error
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a TSQL query is valid
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns true if the query is valid, false otherwise
|
||||
*/
|
||||
export function isValidTSQLQuery(query: string): boolean {
|
||||
if (!query.trim()) {
|
||||
return true; // Empty queries are considered valid
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for a TSQL query, if any
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns Error message if invalid, null if valid
|
||||
*/
|
||||
export function getTSQLError(query: string): string | null {
|
||||
if (!query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@ export function EnvironmentCombo({
|
||||
environment,
|
||||
className,
|
||||
iconClassName,
|
||||
tooltipSideOffset,
|
||||
tooltipSide,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
@@ -62,7 +66,11 @@ export function EnvironmentCombo({
|
||||
environment={environment}
|
||||
className={cn("size-4.5 shrink-0", iconClassName)}
|
||||
/>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
tooltipSideOffset={tooltipSideOffset}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,9 +78,13 @@ export function EnvironmentCombo({
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -115,9 +127,10 @@ export function EnvironmentLabel({
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
side={tooltipSide}
|
||||
variant="dark"
|
||||
sideOffset={34}
|
||||
sideOffset={tooltipSideOffset}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
CircleStackIcon,
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
@@ -13,11 +14,13 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
MagnifyingGlassCircleIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
@@ -31,6 +34,7 @@ import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { Avatar } from "~/components/primitives/Avatar";
|
||||
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
@@ -51,6 +55,7 @@ import {
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
@@ -93,6 +98,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
export type SideMenuProject = Pick<
|
||||
@@ -125,6 +131,7 @@ export function SideMenu({
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -267,6 +274,16 @@ export function SideMenu({
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
badge={<AlphaBadge />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SideMenuSection title="Waitpoints">
|
||||
|
||||
@@ -389,7 +389,7 @@ export const LinkButton = ({
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "w-fit")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
|
||||
@@ -72,23 +72,24 @@ export function CopyableText({
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
|
||||
"cursor-pointer bg-transparent px-1 py-0 text-left text-text-dimmed transition-colors hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="transition-colors group-hover/button:text-text-bright">{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Click to copy"}
|
||||
className="font-sans px-2 py-1"
|
||||
content={copied ? "Copied" : "Copy"}
|
||||
className="px-2 py-1 font-sans"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { Fragment, type ReactNode, useEffect, useState } from "react";
|
||||
import { Fragment, memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
// Cache the browser's local timezone - resolved once and reused
|
||||
let cachedLocalTimeZone: string | null = null;
|
||||
|
||||
function getLocalTimeZone(): string {
|
||||
if (cachedLocalTimeZone === null) {
|
||||
cachedLocalTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
return cachedLocalTimeZone;
|
||||
}
|
||||
|
||||
// For SSR compatibility: returns "UTC" on server, actual timezone on client
|
||||
function subscribeToTimeZone() {
|
||||
// No-op - timezone doesn't change
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function getTimeZoneSnapshot(): string {
|
||||
return getLocalTimeZone();
|
||||
}
|
||||
|
||||
function getServerTimeZoneSnapshot(): string {
|
||||
return "UTC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the browser's local timezone.
|
||||
* Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server,
|
||||
* actual timezone on client. The timezone is cached and only resolved once.
|
||||
*/
|
||||
export function useLocalTimeZone(): string {
|
||||
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
|
||||
}
|
||||
|
||||
type DateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
@@ -28,23 +61,9 @@ export const DateTime = ({
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setLocalTimeZone(resolvedOptions.timeZone);
|
||||
}, []);
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
);
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
|
||||
const formattedDateTime = (
|
||||
<Fragment>
|
||||
@@ -62,7 +81,20 @@ export const DateTime = ({
|
||||
|
||||
if (!showTooltip) return formattedDateTime;
|
||||
|
||||
return <SimpleTooltip button={formattedDateTime} content={tooltipContent} side="right" />;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={formattedDateTime}
|
||||
content={
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
}
|
||||
side="right"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatDateTime(
|
||||
@@ -128,8 +160,9 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
}
|
||||
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -137,29 +170,13 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hou
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
// Initial formatted values
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
|
||||
// Check if we should show the date
|
||||
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
|
||||
|
||||
// State for the formatted time
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(
|
||||
realPrevDate && isSameDay(realDate, realPrevDate) ? initialTimeOnly : initialWithDate
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
const userTimeZone = resolvedOptions.timeZone;
|
||||
|
||||
// Check if we should show the date
|
||||
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
|
||||
|
||||
// Format with appropriate function
|
||||
setFormattedDateTime(
|
||||
showDatePart
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
|
||||
);
|
||||
}, [locales, realDate, realPrevDate, hour12]);
|
||||
// Format with appropriate function
|
||||
const formattedDateTime = showDatePart
|
||||
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
@@ -174,7 +191,12 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
// Format with date and time
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatSmartDateTime(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -189,7 +211,12 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[], ho
|
||||
}
|
||||
|
||||
// Format time only
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatTimeOnly(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
@@ -201,7 +228,7 @@ function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12:
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = ({
|
||||
const DateTimeAccurateInner = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
previousDate = null,
|
||||
@@ -210,7 +237,7 @@ export const DateTimeAccurate = ({
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -218,19 +245,16 @@ export const DateTimeAccurate = ({
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setLocalTimeZone(resolvedOptions.timeZone);
|
||||
}, []);
|
||||
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
const formattedDateTime = useMemo(() => {
|
||||
return hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
}, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
|
||||
if (!showTooltip)
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
@@ -253,7 +277,34 @@ export const DateTimeAccurate = ({
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function areDateTimePropsEqual(prev: DateTimeProps, next: DateTimeProps): boolean {
|
||||
// Compare Date objects by timestamp value, not reference
|
||||
const prevTime = prev.date instanceof Date ? prev.date.getTime() : prev.date;
|
||||
const nextTime = next.date instanceof Date ? next.date.getTime() : next.date;
|
||||
if (prevTime !== nextTime) return false;
|
||||
|
||||
const prevPrevTime =
|
||||
prev.previousDate instanceof Date ? prev.previousDate.getTime() : prev.previousDate;
|
||||
const nextPrevTime =
|
||||
next.previousDate instanceof Date ? next.previousDate.getTime() : next.previousDate;
|
||||
if (prevPrevTime !== nextPrevTime) return false;
|
||||
|
||||
return (
|
||||
prev.timeZone === next.timeZone &&
|
||||
prev.showTooltip === next.showTooltip &&
|
||||
prev.hideDate === next.hideDate &&
|
||||
prev.hour12 === next.hour12
|
||||
);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = memo(DateTimeAccurateInner, areDateTimePropsEqual);
|
||||
|
||||
function formatDateTimeAccurate(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -269,21 +320,21 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[],
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
|
||||
}, [locales, realDate, hour12]);
|
||||
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
function formatDateTimeShort(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
@@ -310,14 +361,17 @@ function DateTimeTooltipContent({
|
||||
isoDateTime,
|
||||
icon,
|
||||
}: DateTimeTooltipContentProps) {
|
||||
const getUtcOffset = () => {
|
||||
if (title !== "Local") return "";
|
||||
const offset = -new Date().getTimezoneOffset();
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.abs(Math.floor(offset / 60));
|
||||
const minutes = Math.abs(offset % 60);
|
||||
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
|
||||
};
|
||||
const getUtcOffset = useMemo(
|
||||
() => () => {
|
||||
if (title !== "Local") return "";
|
||||
const offset = -new Date().getTimezoneOffset();
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.abs(Math.floor(offset / 60));
|
||||
const minutes = Math.abs(offset % 60);
|
||||
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
|
||||
},
|
||||
[title]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -3,10 +3,12 @@ import { cn } from "~/utils/cn";
|
||||
export function FormButtons({
|
||||
cancelButton,
|
||||
confirmButton,
|
||||
defaultAction,
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -16,6 +18,17 @@ export function FormButtons({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{defaultAction && (
|
||||
<button
|
||||
type="submit"
|
||||
name={defaultAction.name}
|
||||
value={defaultAction.value}
|
||||
disabled={defaultAction.disabled}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import React, { type ReactNode, forwardRef, useState, useContext, createContext } from "react";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import React, { type ReactNode, createContext, forwardRef, useContext, useState } from "react";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
|
||||
import { InfoIconTooltip } from "./Tooltip";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
bright: {
|
||||
@@ -15,6 +17,14 @@ const variants = {
|
||||
menuButtonDivider: "group-hover/table-row:border-charcoal-600/70",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
"bright/no-hover": {
|
||||
header: "bg-transparent",
|
||||
cell: "group-hover/table-row:bg-transparent",
|
||||
stickyCell: "bg-background-bright",
|
||||
menuButton: "bg-background-bright",
|
||||
menuButtonDivider: "",
|
||||
rowSelected: "bg-charcoal-750",
|
||||
},
|
||||
dimmed: {
|
||||
header: "bg-background-dimmed",
|
||||
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
@@ -96,7 +106,7 @@ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
}
|
||||
);
|
||||
|
||||
type TableRowProps = {
|
||||
type TableRowProps = JSX.IntrinsicElements["tr"] & {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
@@ -104,11 +114,12 @@ type TableRowProps = {
|
||||
};
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, disabled, isSelected, children }, ref) => {
|
||||
({ className, disabled, isSelected, children, ...props }, ref) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
"group/table-row relative w-full outline-none after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
isSelected && variants[variant].rowSelected,
|
||||
@@ -146,6 +157,8 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
break;
|
||||
}
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<th
|
||||
ref={ref}
|
||||
@@ -157,6 +170,8 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{hiddenLabel ? (
|
||||
<span className="sr-only">{children}</span>
|
||||
@@ -168,7 +183,11 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
<InfoIconTooltip content={tooltip} contentClassName="normal-case tracking-normal" />
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
@@ -269,6 +288,60 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
);
|
||||
|
||||
type CopyableTableCellProps = TableCellProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableCellProps>(
|
||||
({ value, children, className, ...props }, ref) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(value);
|
||||
|
||||
return (
|
||||
<TableCell ref={ref} className={className} {...props}>
|
||||
<div
|
||||
className="relative flex items-center"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{isHovered && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copy();
|
||||
}}
|
||||
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const TableCellChevron = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
@@ -85,6 +85,7 @@ function SimpleTooltip({
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
@@ -111,20 +112,28 @@ export function InfoIconTooltip({
|
||||
buttonClassName,
|
||||
contentClassName,
|
||||
variant = "basic",
|
||||
disableHoverableContent = false,
|
||||
enabled = true,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
buttonClassName?: string;
|
||||
contentClassName?: string;
|
||||
variant?: Variant;
|
||||
disableHoverableContent?: boolean;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const icon = (
|
||||
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
|
||||
);
|
||||
|
||||
if (!enabled) return icon;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
|
||||
}
|
||||
button={icon}
|
||||
content={content}
|
||||
variant={variant}
|
||||
className={contentClassName}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
export const allBatchStatuses = [
|
||||
"PROCESSING",
|
||||
"PENDING",
|
||||
"COMPLETED",
|
||||
"PARTIAL_FAILED",
|
||||
"ABORTED",
|
||||
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PROCESSING: "The batch is being processed and runs are being created.",
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
|
||||
ABORTED: "The batch was aborted because child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "PARTIAL_FAILED":
|
||||
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "text-blue-500";
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
case "PARTIAL_FAILED":
|
||||
return "text-warning";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PROCESSING":
|
||||
return "Processing";
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "PARTIAL_FAILED":
|
||||
return "Partial failure";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
default: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { RectangleStackIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function QueueName({
|
||||
name,
|
||||
type,
|
||||
paused,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
type: "task" | "custom";
|
||||
paused?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
{type === "task" ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TaskIconSmall
|
||||
className={cn("size-[1.125rem] text-blue-500", paused && "opacity-50")}
|
||||
/>
|
||||
}
|
||||
content={`This queue was automatically created from your "${name}" task`}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<RectangleStackIcon
|
||||
className={cn("size-[1.125rem] text-purple-500", paused && "opacity-50")}
|
||||
/>
|
||||
}
|
||||
content={`This is a custom queue you added in your code.`}
|
||||
/>
|
||||
)}
|
||||
<span className={paused ? "opacity-50" : undefined}>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -236,43 +236,71 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function runStatusTitle(status: TaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "DELAYED":
|
||||
return "Delayed";
|
||||
case "PENDING":
|
||||
return "Queued";
|
||||
case "PENDING_VERSION":
|
||||
case "WAITING_FOR_DEPLOY":
|
||||
return "Pending version";
|
||||
case "DEQUEUED":
|
||||
return "Dequeued";
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "WAITING_TO_RESUME":
|
||||
return "Waiting";
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return "Reattempting";
|
||||
case "PAUSED":
|
||||
return "Paused";
|
||||
case "CANCELED":
|
||||
return "Canceled";
|
||||
case "INTERRUPTED":
|
||||
return "Interrupted";
|
||||
case "COMPLETED_SUCCESSFULLY":
|
||||
return "Completed";
|
||||
case "COMPLETED_WITH_ERRORS":
|
||||
return "Failed";
|
||||
case "SYSTEM_FAILURE":
|
||||
return "System failure";
|
||||
case "CRASHED":
|
||||
return "Crashed";
|
||||
case "EXPIRED":
|
||||
return "Expired";
|
||||
case "TIMED_OUT":
|
||||
return "Timed out";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
export function runStatusTitle(status: TaskRunStatus): RunFriendlyStatus {
|
||||
return runStatusTitleFromStatus[status];
|
||||
}
|
||||
|
||||
export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRunStatus {
|
||||
const result = titlesStatusesArray.find(([_, f]) => f === friendly);
|
||||
if (!result) {
|
||||
throw new Error(`Unknown friendly status: ${friendly}`);
|
||||
}
|
||||
return result[0] as TaskRunStatus;
|
||||
}
|
||||
|
||||
export const runFriendlyStatus = [
|
||||
"Delayed",
|
||||
"Queued",
|
||||
"Pending version",
|
||||
"Dequeued",
|
||||
"Executing",
|
||||
"Waiting",
|
||||
"Reattempting",
|
||||
"Paused",
|
||||
"Canceled",
|
||||
"Interrupted",
|
||||
"Completed",
|
||||
"Failed",
|
||||
"System failure",
|
||||
"Crashed",
|
||||
"Expired",
|
||||
"Timed out",
|
||||
] as const;
|
||||
|
||||
export type RunFriendlyStatus = (typeof runFriendlyStatus)[number];
|
||||
|
||||
/**
|
||||
* Check if a value is a valid TaskRunStatus
|
||||
*/
|
||||
export function isTaskRunStatus(value: unknown): value is TaskRunStatus {
|
||||
return typeof value === "string" && allTaskRunStatuses.includes(value as TaskRunStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a valid RunFriendlyStatus
|
||||
*/
|
||||
export function isRunFriendlyStatus(value: unknown): value is RunFriendlyStatus {
|
||||
return typeof value === "string" && runFriendlyStatus.includes(value as RunFriendlyStatus);
|
||||
}
|
||||
|
||||
export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus> = {
|
||||
DELAYED: "Delayed",
|
||||
PENDING: "Queued",
|
||||
PENDING_VERSION: "Pending version",
|
||||
WAITING_FOR_DEPLOY: "Pending version",
|
||||
DEQUEUED: "Dequeued",
|
||||
EXECUTING: "Executing",
|
||||
WAITING_TO_RESUME: "Waiting",
|
||||
RETRYING_AFTER_FAILURE: "Reattempting",
|
||||
PAUSED: "Paused",
|
||||
CANCELED: "Canceled",
|
||||
INTERRUPTED: "Interrupted",
|
||||
COMPLETED_SUCCESSFULLY: "Completed",
|
||||
COMPLETED_WITH_ERRORS: "Failed",
|
||||
SYSTEM_FAILURE: "System failure",
|
||||
CRASHED: "Crashed",
|
||||
EXPIRED: "Expired",
|
||||
TIMED_OUT: "Timed out",
|
||||
};
|
||||
|
||||
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -63,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -73,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -82,8 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1");
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import {
|
||||
createReadableStreamFromReadable,
|
||||
type DataFunctionArgs,
|
||||
type EntryContext,
|
||||
} from "@remix-run/node"; // or cloudflare/deno
|
||||
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { parseAcceptLanguage } from "intl-parse-accept-language";
|
||||
import isbot from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { PassThrough } from "stream";
|
||||
import * as Worker from "~/services/worker.server";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
|
||||
import {
|
||||
OperatingSystemContextProvider,
|
||||
OperatingSystemPlatform,
|
||||
} from "./components/primitives/OperatingSystemProvider";
|
||||
import { Prisma } from "./db.server";
|
||||
import { env } from "./env.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { bootstrap } from "./bootstrap";
|
||||
import { wrapHandleErrorWithSentry } from "@sentry/remix";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import {
|
||||
registerRunEngineEventBusHandlers,
|
||||
setupBatchQueueCallbacks,
|
||||
} from "./v3/runEngineHandlers.server";
|
||||
|
||||
const ABORT_DELAY = 30000;
|
||||
|
||||
@@ -228,19 +234,13 @@ process.on("uncaughtException", (error, origin) => {
|
||||
});
|
||||
|
||||
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
|
||||
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
|
||||
|
||||
export { apiRateLimiter } from "./services/apiRateLimit.server";
|
||||
export { engineRateLimiter } from "./services/engineRateLimit.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
export { socketIo } from "./v3/handleSocketIo.server";
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
|
||||
@@ -521,6 +521,7 @@ const EnvironmentSchema = z
|
||||
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||||
|
||||
CENTS_PER_RUN: z.coerce.number().default(0),
|
||||
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
|
||||
|
||||
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
|
||||
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
|
||||
@@ -528,6 +529,7 @@ const EnvironmentSchema = z
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
@@ -537,6 +539,14 @@ const EnvironmentSchema = z
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
// 2-phase batch API settings
|
||||
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
|
||||
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
|
||||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
REALTIME_STREAM_TTL: z.coerce
|
||||
@@ -602,6 +612,12 @@ const EnvironmentSchema = z
|
||||
.default(60_000),
|
||||
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
|
||||
|
||||
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
|
||||
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 60), // 1 hour
|
||||
|
||||
RUN_ENGINE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -931,6 +947,23 @@ const EnvironmentSchema = z
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
// BatchQueue DRR settings (Run Engine v2)
|
||||
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
|
||||
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
|
||||
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
|
||||
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
|
||||
// Number of master queue shards for horizontal scaling
|
||||
BATCH_QUEUE_SHARD_COUNT: z.coerce.number().int().default(1),
|
||||
// Maximum queues to fetch from master queue per iteration
|
||||
BATCH_QUEUE_MASTER_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||||
// Enable worker queue for two-stage processing (claim messages, push to worker queue, process from worker queue)
|
||||
BATCH_QUEUE_WORKER_QUEUE_ENABLED: BoolEnv.default(true),
|
||||
// Worker queue blocking timeout in seconds (for two-stage processing, only used when BATCH_QUEUE_WORKER_QUEUE_ENABLED is true)
|
||||
BATCH_QUEUE_WORKER_QUEUE_TIMEOUT_SECONDS: z.coerce.number().int().default(10),
|
||||
// Global rate limit: max items processed per second across all consumers
|
||||
// If not set, no global rate limiting is applied
|
||||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||||
|
||||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||||
@@ -1236,6 +1269,7 @@ const EnvironmentSchema = z
|
||||
EVENT_LOOP_MONITOR_THRESHOLD_MS: z.coerce.number().int().default(100),
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
|
||||
EVENT_LOOP_MONITOR_NOTIFY_ENABLED: z.string().default("0"),
|
||||
|
||||
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@ import { signalsEmitter } from "./services/signals.server";
|
||||
|
||||
const THRESHOLD_NS = env.EVENT_LOOP_MONITOR_THRESHOLD_MS * 1e6;
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const RED = "\x1b[31m";
|
||||
const YELLOW = "\x1b[33m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
function notifyEventLoopBlocked(timeMs: number, asyncType: string): void {
|
||||
if (env.EVENT_LOOP_MONITOR_NOTIFY_ENABLED !== "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`${RED}⚠️ Event loop blocked${RESET} for ${YELLOW}${timeMs.toFixed(
|
||||
1
|
||||
)}ms${RESET} (${asyncType})`
|
||||
);
|
||||
}
|
||||
|
||||
const cache = new Map<number, { type: string; start?: [number, number]; parentCtx?: Context }>();
|
||||
|
||||
function init(asyncId: number, type: string, triggerAsyncId: number, resource: any) {
|
||||
@@ -66,6 +83,8 @@ function after(asyncId: number) {
|
||||
);
|
||||
|
||||
newSpan.end();
|
||||
|
||||
notifyEventLoopBlocked(time, cached.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { useOptionalOrganization } from "./useOrganizations";
|
||||
|
||||
/**
|
||||
* Hook to access organization-level feature flags.
|
||||
* Returns the feature flags from the current organization, or an empty object if no organization is found.
|
||||
*/
|
||||
export function useFeatureFlags(matches?: UIMatch[]) {
|
||||
const org = useOptionalOrganization(matches);
|
||||
return org?.featureFlags ?? {};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./SelectBestEnvironmentPresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -132,6 +133,7 @@ export class OrganizationsPresenter {
|
||||
slug: true,
|
||||
title: true,
|
||||
avatar: true,
|
||||
featureFlags: true,
|
||||
projects: {
|
||||
where: { deletedAt: null, version: "V3" },
|
||||
select: {
|
||||
@@ -139,6 +141,7 @@ export class OrganizationsPresenter {
|
||||
slug: true,
|
||||
name: true,
|
||||
updatedAt: true,
|
||||
externalRef: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
@@ -151,16 +154,23 @@ export class OrganizationsPresenter {
|
||||
});
|
||||
|
||||
return orgs.map((org) => {
|
||||
const flagsResult = org.featureFlags
|
||||
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
|
||||
: ({ success: false } as const);
|
||||
const flags = flagsResult.success ? flagsResult.data : {};
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
title: org.title,
|
||||
avatar: parseAvatar(org.avatar, defaultAvatar),
|
||||
featureFlags: flags,
|
||||
projects: org.projects.map((project) => ({
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
name: project.name,
|
||||
updatedAt: project.updatedAt,
|
||||
externalRef: project.externalRef,
|
||||
})),
|
||||
membersCount: org._count.members,
|
||||
};
|
||||
|
||||
@@ -195,7 +195,7 @@ WHERE
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING";
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { type BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type BatchPresenterOptions = {
|
||||
environmentId: string;
|
||||
batchId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
|
||||
|
||||
export class BatchPresenter extends BasePresenter {
|
||||
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
batchVersion: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
completedAt: true,
|
||||
processingStartedAt: true,
|
||||
processingCompletedAt: true,
|
||||
successfulRunCount: true,
|
||||
failedRunCount: true,
|
||||
idempotencyKey: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
errorCode: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
index: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Batch not found");
|
||||
}
|
||||
|
||||
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
|
||||
const isV2 = batch.batchVersion === "runengine:v2";
|
||||
|
||||
// For v2 batches in PROCESSING state, get live progress from Redis
|
||||
// This provides real-time updates without waiting for the batch to complete
|
||||
let liveSuccessCount = batch.successfulRunCount ?? 0;
|
||||
let liveFailureCount = batch.failedRunCount ?? 0;
|
||||
|
||||
if (isV2 && batch.status === "PROCESSING") {
|
||||
const liveProgress = await engine.getBatchQueueProgress(batch.id);
|
||||
if (liveProgress) {
|
||||
liveSuccessCount = liveProgress.successCount;
|
||||
liveFailureCount = liveProgress.failureCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
status: batch.status as BatchTaskRunStatus,
|
||||
runCount: batch.runCount,
|
||||
batchVersion: batch.batchVersion,
|
||||
isV2,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
completedAt: batch.completedAt?.toISOString(),
|
||||
processingStartedAt: batch.processingStartedAt?.toISOString(),
|
||||
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
|
||||
finishedAt: batch.completedAt
|
||||
? batch.completedAt.toISOString()
|
||||
: hasFinished
|
||||
? batch.updatedAt.toISOString()
|
||||
: undefined,
|
||||
hasFinished,
|
||||
successfulRunCount: liveSuccessCount,
|
||||
failedRunCount: liveFailureCount,
|
||||
idempotencyKey: batch.idempotencyKey,
|
||||
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
|
||||
errors: batch.errors.map((error) => ({
|
||||
id: error.id,
|
||||
index: error.index,
|
||||
taskIdentifier: error.taskIdentifier,
|
||||
error: error.error,
|
||||
errorCode: error.errorCode,
|
||||
createdAt: error.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defaultQuery } from "~/v3/querySchemas";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
|
||||
export type QueryHistoryItem = {
|
||||
id: string;
|
||||
query: string;
|
||||
scope: QueryScope;
|
||||
createdAt: Date;
|
||||
userName: string | null;
|
||||
};
|
||||
|
||||
export class QueryPresenter extends BasePresenter {
|
||||
public async call({ organizationId }: { organizationId: string }) {
|
||||
const history = await this._replica.customerQuery.findMany({
|
||||
where: { organizationId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
query: true,
|
||||
scope: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { name: true, displayName: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
defaultQuery,
|
||||
history: history.map(
|
||||
(q): QueryHistoryItem => ({
|
||||
id: q.id,
|
||||
query: q.query,
|
||||
scope: q.scope.toLowerCase() as QueryScope,
|
||||
createdAt: q.createdAt,
|
||||
userName: q.user?.displayName ?? q.user?.name ?? null,
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
environmentId: run.runtimeEnvironment.id,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
|
||||
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
|
||||
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
|
||||
queue: {
|
||||
name: run.queue,
|
||||
@@ -357,6 +358,8 @@ export class SpanPresenter extends BasePresenter {
|
||||
//idempotency
|
||||
idempotencyKey: true,
|
||||
idempotencyKeyExpiresAt: true,
|
||||
//debounce
|
||||
debounce: true,
|
||||
//delayed
|
||||
delayUntil: true,
|
||||
//ttl
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { motion } from "framer-motion";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
const BatchParamSchema = EnvironmentParamSchema.extend({
|
||||
batchParam: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug, projectParam, envParam, batchParam } =
|
||||
BatchParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const presenter = new BatchPresenter();
|
||||
const [error, data] = await tryCatch(
|
||||
presenter.call({
|
||||
environmentId: environment.id,
|
||||
batchId: batchParam,
|
||||
userId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ batch: data });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batch } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Auto-reload when batch is still in progress
|
||||
useAutoRevalidate({
|
||||
interval: 1000,
|
||||
onFocus: true,
|
||||
disabled: batch.hasFinished,
|
||||
});
|
||||
|
||||
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
{/* Header */}
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
|
||||
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{descriptionForBatchStatus(batch.status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="space-y-3">
|
||||
{/* Progress meter for v2 batches */}
|
||||
{showProgressMeter && (
|
||||
<div className="px-3 pt-3">
|
||||
<BatchProgressMeter
|
||||
successCount={batch.successfulRunCount}
|
||||
failureCount={batch.failedRunCount}
|
||||
totalCount={batch.runCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="px-3 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.friendlyId} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<BatchStatusCombo status={batch.status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total runs</Property.Label>
|
||||
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.isV2 && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Successfully created</Property.Label>
|
||||
<Property.Value className="text-success">
|
||||
{formatNumber(batch.successfulRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.failedRunCount > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Failed to create</Property.Label>
|
||||
<Property.Value className="text-error">
|
||||
{formatNumber(batch.failedRunCount)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{batch.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{batch.processingStartedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingStartedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{batch.processingCompletedAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Processing completed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={batch.processingCompletedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Finished</Property.Label>
|
||||
<Property.Value>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
{/* Errors section */}
|
||||
{batch.errors.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
Run creation errors ({batch.errors.length})
|
||||
</Header3>
|
||||
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
|
||||
{batch.errors.map((error) => (
|
||||
<div key={error.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-text-dimmed">
|
||||
Item #{error.index}
|
||||
</span>
|
||||
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
|
||||
</div>
|
||||
{error.errorCode && (
|
||||
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
|
||||
{error.errorCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-1 text-error">
|
||||
{error.error}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={v3BatchRunsPath(organization, project, environment, batch)}
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchProgressMeterProps = {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
|
||||
const processedCount = successCount + failureCount;
|
||||
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
|
||||
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Paragraph variant="small/bright">Run creation progress</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
{formatNumber(processedCount)}/{formatNumber(totalCount)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
|
||||
<motion.div
|
||||
className="absolute left-0 top-0 h-full bg-success"
|
||||
initial={{ width: `${successPercentage}%` }}
|
||||
animate={{ width: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 h-full bg-error"
|
||||
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-success" />
|
||||
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
|
||||
</div>
|
||||
{failureCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-[1px] bg-error" />
|
||||
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+72
-81
@@ -1,10 +1,6 @@
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type BatchList,
|
||||
type BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BatchPath,
|
||||
v3BatchRunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { batchParam } = useParams();
|
||||
const isShowingInspector = batchParam !== undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -123,22 +124,34 @@ export default function Page() {
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="batches-main" min={"100px"}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
hasAnyBatches={hasAnyBatches}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{isShowingInspector && (
|
||||
<>
|
||||
<ResizableHandle id="batches-handle" />
|
||||
<ResizablePanel id="batches-inspector" min="100px" default="500px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
@@ -147,10 +160,14 @@ export default function Page() {
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const location = useLocation();
|
||||
const isLoading =
|
||||
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { batchParam } = useParams();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, environment, batch);
|
||||
batches.map((batch) => {
|
||||
const basePath = v3BatchPath(organization, project, environment, batch);
|
||||
const inspectorPath = `${basePath}${location.search}`;
|
||||
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
|
||||
const isSelected = batchParam === batch.friendlyId;
|
||||
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
|
||||
<TableCell to={inspectorPath} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
|
||||
<TableCell
|
||||
to={inspectorPath}
|
||||
className="w-[1%]"
|
||||
actionClassName="pr-0 tabular-nums"
|
||||
>
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={inspectorPath}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
<BatchActionsCell runsPath={runsPath} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
function BatchActionsCell({ runsPath }: { runsPath: string }) {
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
hiddenButtons={
|
||||
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
|
||||
View runs
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+37
-10
@@ -1,6 +1,7 @@
|
||||
import { conform, useFieldList, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
@@ -331,7 +333,7 @@ function Upgradable({
|
||||
disabled={unallocated < 0 ? false : allocationModified}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
|
||||
@@ -379,8 +381,15 @@ function Upgradable({
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={allocationModified ? undefined : "after:bg-transparent"}>
|
||||
<TableCell colSpan={2} className="py-0">
|
||||
<TableRow
|
||||
className={
|
||||
allocationModified || unallocated > 0 ? undefined : "after:bg-transparent"
|
||||
}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className={cn("py-0", (unallocated > 0 || allocationModified) && "pr-0")}
|
||||
>
|
||||
<div className="flex h-10 items-center">
|
||||
{allocationModified ? (
|
||||
unallocated < 0 ? (
|
||||
@@ -419,6 +428,16 @@ function Upgradable({
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : unallocated > 0 ? (
|
||||
<div className="flex h-full w-full items-center justify-between bg-success/10 px-2.5">
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<InformationCircleIcon className="size-4 text-success" />
|
||||
<span className="text-success">
|
||||
You have {unallocated} extra concurrency available to allocate below.
|
||||
</span>
|
||||
</div>
|
||||
<ArrowDownIcon className="size-4 animate-bounce text-success" />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
@@ -434,7 +453,7 @@ function Upgradable({
|
||||
<div className="flex items-center pb-1">
|
||||
<Header3 className="grow">Concurrency allocation</Header3>
|
||||
</div>
|
||||
<Table>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
@@ -452,7 +471,12 @@ function Upgradable({
|
||||
{environments.map((environment, index) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentCombo environment={environment} />
|
||||
<EnvironmentCombo
|
||||
environment={environment}
|
||||
className="max-w-[18ch]"
|
||||
tooltipSideOffset={6}
|
||||
tooltipSide="top"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
@@ -523,7 +547,7 @@ function NotUpgradable({ environments }: { environments: EnvironmentWithConcurre
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Table>
|
||||
<Table variant="bright/no-hover">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
@@ -682,7 +706,7 @@ function PurchaseConcurrencyModal({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({extraConcurrency / concurrencyPricing.stepSize} bundles)
|
||||
({simplur`${extraConcurrency / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
@@ -703,8 +727,11 @@ function PurchaseConcurrencyModal({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "}
|
||||
{formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
||||
(
|
||||
{simplur`${
|
||||
(amountValue - extraConcurrency) / concurrencyPricing.stepSize
|
||||
} bundle[|s]`}{" "}
|
||||
@ {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
@@ -723,7 +750,7 @@ function PurchaseConcurrencyModal({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({amountValue / concurrencyPricing.stepSize} bundles)
|
||||
({simplur`${amountValue / concurrencyPricing.stepSize} bundle[|s]`})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
|
||||
+16
-25
@@ -9,14 +9,13 @@ import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
json,
|
||||
redirectDocument,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
@@ -159,19 +158,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
//use redirectDocument because it reloads the page
|
||||
return redirectDocument(
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
refresh: "true",
|
||||
},
|
||||
}
|
||||
);
|
||||
return json({ ...submission, success: true });
|
||||
}
|
||||
case "delete": {
|
||||
const repository = new EnvironmentVariablesRepository(prisma);
|
||||
@@ -417,16 +404,20 @@ function EditEnvironmentVariablePanel({
|
||||
revealAll: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const lastSubmission = fetcher.data as any;
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "edit";
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
// Close dialog on successful submission
|
||||
useEffect(() => {
|
||||
if (lastSubmission?.success && fetcher.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [lastSubmission?.success, fetcher.state]);
|
||||
|
||||
const [form, { id, environmentId, value }] = useForm({
|
||||
id: "edit-environment-variable",
|
||||
id: `edit-environment-variable-${variable.id}-${variable.environment.id}`,
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
@@ -444,7 +435,7 @@ function EditEnvironmentVariablePanel({
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Edit environment variable</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<fetcher.Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="edit" />
|
||||
<input {...conform.input(id, { type: "hidden" })} value={variable.id} />
|
||||
<input
|
||||
@@ -490,7 +481,7 @@ function EditEnvironmentVariablePanel({
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+2170
File diff suppressed because it is too large
Load Diff
+13
-34
@@ -80,6 +80,7 @@ import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
|
||||
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { QueueName } from "~/components/runs/v3/QueueName";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
@@ -392,7 +393,7 @@ export default function Page() {
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
tooltip="View runs"
|
||||
tooltip="View running runs"
|
||||
/>
|
||||
}
|
||||
compactThreshold={1000000}
|
||||
@@ -499,7 +500,7 @@ export default function Page() {
|
||||
>
|
||||
Limited by
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-[1%] pl-24">
|
||||
<TableHeaderCell className="w-[1%] pl-32">
|
||||
<span className="sr-only">Pause/resume</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
@@ -516,34 +517,7 @@ export default function Page() {
|
||||
<TableRow key={queue.name}>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-2">
|
||||
{queue.type === "task" ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TaskIconSmall
|
||||
className={cn(
|
||||
"size-[1.125rem] text-blue-500",
|
||||
queue.paused && "opacity-50"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
content={`This queue was automatically created from your "${queue.name}" task`}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<RectangleStackIcon
|
||||
className={cn(
|
||||
"size-[1.125rem] text-purple-500",
|
||||
queue.paused && "opacity-50"
|
||||
)}
|
||||
/>
|
||||
}
|
||||
content={`This is a custom queue you added in your code.`}
|
||||
/>
|
||||
)}
|
||||
<span className={queue.paused ? "opacity-50" : undefined}>
|
||||
{queue.name}
|
||||
</span>
|
||||
<QueueName {...queue} />
|
||||
{queue.concurrency?.overriddenAt ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
@@ -571,7 +545,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined
|
||||
)}
|
||||
>
|
||||
@@ -580,7 +554,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.running > 0 && "text-text-bright",
|
||||
isAtLimit && "text-warning"
|
||||
@@ -591,7 +565,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
"w-[1%] pl-16 tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
@@ -601,7 +575,7 @@ export default function Page() {
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%]",
|
||||
"w-[1%] pl-16",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning",
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
@@ -1003,6 +977,11 @@ function QueueOverrideConcurrencyButton({
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
defaultAction={{
|
||||
name: "action",
|
||||
value: "queue-override",
|
||||
disabled: isLoading || !concurrencyLimit,
|
||||
}}
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
+92
-60
@@ -2,7 +2,6 @@ import {
|
||||
ArrowUturnLeftIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
MagnifyingGlassPlusIcon,
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
import { useLoaderData, useRevalidator } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
|
||||
import { type Virtualizer } from "@tanstack/react-virtual";
|
||||
@@ -26,6 +26,8 @@ import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { ChevronExtraSmallDown } from "~/assets/icons/ChevronExtraSmallDown";
|
||||
import { ChevronExtraSmallUp } from "~/assets/icons/ChevronExtraSmallUp";
|
||||
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
|
||||
import { MoveUpIcon } from "~/assets/icons/MoveUpIcon";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
@@ -35,6 +37,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTimeShort } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
@@ -62,6 +65,7 @@ import {
|
||||
import { type NodesState } from "~/components/primitives/TreeView/reducer";
|
||||
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
|
||||
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import {
|
||||
SpanTitle,
|
||||
@@ -69,6 +73,7 @@ import {
|
||||
eventBorderClassName,
|
||||
} from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
@@ -76,10 +81,16 @@ import { useInitialDimensions } from "~/hooks/useInitialDimensions";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getResizableSnapshot } from "~/services/resizablePanel.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -94,18 +105,9 @@ import {
|
||||
v3RunStreamingPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -210,7 +212,10 @@ async function getRunsListFromTableState({
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
|
||||
if (
|
||||
currentRunIndex === currentPageResult.runs.length - 1 &&
|
||||
currentPageResult.pagination.next
|
||||
) {
|
||||
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
@@ -313,7 +318,16 @@ export default function Page() {
|
||||
const tabParam = value("tab") ?? undefined;
|
||||
const spanParam = value("span") ?? undefined;
|
||||
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan: !!spanParam,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -323,13 +337,21 @@ export default function Page() {
|
||||
to: v3RunsPath(organization, project, environment, filters),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={<>
|
||||
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
|
||||
{tableState && (<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>)}
|
||||
</>}
|
||||
title={
|
||||
<div className="flex items-center gap-x-0">
|
||||
<CopyableText
|
||||
value={run.friendlyId}
|
||||
variant="text-below"
|
||||
className="-ml-[0.4375rem] h-6 px-1.5 font-mono text-xs hover:text-text-bright"
|
||||
/>
|
||||
{tableState && (
|
||||
<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
|
||||
<PageAccessories>
|
||||
@@ -407,16 +429,18 @@ export default function Page() {
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
/>
|
||||
) : (
|
||||
<NoLogsView
|
||||
run={run}
|
||||
/>
|
||||
<NoLogsView run={run} />
|
||||
)}
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
function TraceView({
|
||||
run,
|
||||
trace,
|
||||
maximumLiveReloadingSetting,
|
||||
}: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -875,7 +899,7 @@ function TasksTreeView({
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-4">
|
||||
<div className="grow @container">
|
||||
<div className="hidden items-center gap-4 @[42rem]:flex">
|
||||
<div className="hidden items-center gap-4 @[48rem]:flex">
|
||||
<KeyboardShortcuts
|
||||
expandAllBelowDepth={expandAllBelowDepth}
|
||||
collapseAllBelowDepth={collapseAllBelowDepth}
|
||||
@@ -883,7 +907,7 @@ function TasksTreeView({
|
||||
setShowDurations={setShowDurations}
|
||||
/>
|
||||
</div>
|
||||
<div className="@[42rem]:hidden">
|
||||
<div className="@[48rem]:hidden">
|
||||
<Popover>
|
||||
<PopoverArrowTrigger>Shortcuts</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
@@ -959,6 +983,7 @@ function TimelineView({
|
||||
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
|
||||
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
|
||||
const maxTimelineWidth = minTimelineWidth * 10;
|
||||
const disableSpansAnimations = rootSpanStatus !== "executing";
|
||||
|
||||
//we want to live-update the duration if the root span is still executing
|
||||
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
|
||||
@@ -1130,8 +1155,10 @@ function TimelineView({
|
||||
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.data.isPartial ? `${node.id}-${event.name}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layoutId={
|
||||
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
|
||||
}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1149,8 +1176,10 @@ function TimelineView({
|
||||
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
|
||||
eventBorderClassName(node.data)
|
||||
)}
|
||||
layoutId={node.data.isPartial ? `${node.id}-${event.name}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layoutId={
|
||||
disableSpansAnimations ? undefined : `${node.id}-${event.name}`
|
||||
}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1169,8 +1198,8 @@ function TimelineView({
|
||||
>
|
||||
<motion.div
|
||||
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
|
||||
layoutId={node.data.isPartial ? `mark-${node.id}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
</Timeline.Span>
|
||||
) : null}
|
||||
@@ -1193,6 +1222,7 @@ function TimelineView({
|
||||
}
|
||||
node={node}
|
||||
fadeLeft={isTopSpan && queuedDuration !== undefined}
|
||||
disableAnimations={disableSpansAnimations}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1207,8 +1237,8 @@ function TimelineView({
|
||||
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.data.isPartial ? node.id : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layoutId={disableSpansAnimations ? undefined : node.id}
|
||||
animate={disableSpansAnimations ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1440,8 +1470,14 @@ function SpanWithDuration({
|
||||
showDuration,
|
||||
node,
|
||||
fadeLeft,
|
||||
disableAnimations,
|
||||
...props
|
||||
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
|
||||
}: Timeline.SpanProps & {
|
||||
node: TraceEvent;
|
||||
showDuration: boolean;
|
||||
fadeLeft: boolean;
|
||||
disableAnimations?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Timeline.Span {...props}>
|
||||
<motion.div
|
||||
@@ -1451,8 +1487,8 @@ function SpanWithDuration({
|
||||
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
|
||||
)}
|
||||
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
|
||||
layoutId={node.data.isPartial ? node.id : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layoutId={disableAnimations ? undefined : node.id}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{node.data.isPartial && (
|
||||
<div
|
||||
@@ -1465,12 +1501,12 @@ function SpanWithDuration({
|
||||
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
|
||||
!showDuration && "opacity-0"
|
||||
)}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
|
||||
layout={node.data.isPartial ? "position" : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
layout={disableAnimations ? undefined : "position"}
|
||||
animate={disableAnimations ? false : undefined}
|
||||
>
|
||||
{formatDurationMilliseconds(props.durationMs, {
|
||||
style: "short",
|
||||
@@ -1580,13 +1616,15 @@ function KeyboardShortcuts({
|
||||
}
|
||||
|
||||
function AdjacentRunsShortcuts() {
|
||||
return (<div className="flex items-center gap-0.5">
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Adjacent runs
|
||||
Next/previous run
|
||||
</Paragraph>
|
||||
</div>);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowKeyShortcuts() {
|
||||
@@ -1676,13 +1714,13 @@ function useAdjacentRunPaths({
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan
|
||||
useSpan,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
tableState: string;
|
||||
run: { friendlyId: string, spanId: string };
|
||||
run: { friendlyId: string; spanId: string };
|
||||
runsList: RunsListNavigation | null;
|
||||
tabParam?: string;
|
||||
useSpan?: boolean;
|
||||
@@ -1692,7 +1730,7 @@ function useAdjacentRunPaths({
|
||||
}
|
||||
|
||||
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
|
||||
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
@@ -1748,18 +1786,15 @@ function useAdjacentRunPaths({
|
||||
return [previousRunPath, nextRunPath];
|
||||
}
|
||||
|
||||
|
||||
function PreviousRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
to={to ? to : "#"}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronUpIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
LeadingIcon={ChevronExtraSmallUp}
|
||||
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
|
||||
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "[" }}
|
||||
tooltip="Previous Run"
|
||||
@@ -1774,13 +1809,11 @@ function NextRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
to={to ? to : "#"}
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon={ChevronDownIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
LeadingIcon={ChevronExtraSmallDown}
|
||||
leadingIconClassName="size-3 group-hover/button:text-text-bright transition-colors"
|
||||
className={cn("flex size-6 max-w-6 items-center", !to && "cursor-not-allowed opacity-50")}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "]" }}
|
||||
tooltip="Next Run"
|
||||
@@ -1790,4 +1823,3 @@ function NextRunButton({ to }: { to: string | null }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -298,6 +298,7 @@ function RunsList({
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+1
@@ -318,6 +318,7 @@ export default function Page() {
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ export default function Page() {
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+37
-1
@@ -24,12 +24,13 @@ import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { getBillingAlerts, setBillingAlert } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import {
|
||||
docsPath,
|
||||
OrganizationParamsSchema,
|
||||
@@ -183,6 +184,8 @@ export default function Page() {
|
||||
|
||||
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
|
||||
|
||||
const spikeAlertLevels = [10.0, 20.0, 50.0, 100.0];
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.emails.length > 0) {
|
||||
requestIntent(form.ref.current ?? undefined, list.append(emails.name));
|
||||
@@ -272,6 +275,39 @@ export default function Page() {
|
||||
))}
|
||||
<FormError id={alertLevels.errorId}>{alertLevels.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label>Spike alerts</Label>
|
||||
<InfoIconTooltip
|
||||
content={
|
||||
"Catch runaway usage from bugs or errors. We recommend keeping these enabled as a safety net."
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</div>
|
||||
{spikeAlertLevels.map((level) => (
|
||||
<CheckboxWithLabel
|
||||
name={alertLevels.name}
|
||||
id={`level_${level}`}
|
||||
key={level}
|
||||
value={level.toString()}
|
||||
variant="simple/small"
|
||||
label={
|
||||
<span>
|
||||
{formatNumber(level * 100)}%{" "}
|
||||
<span className="text-text-dimmed">
|
||||
({formatCurrency(Number(dollarAmount) * level, false)})
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
defaultChecked={
|
||||
alerts.alertLevels.includes(level) ||
|
||||
!spikeAlertLevels.some((l) => alerts.alertLevels.includes(l))
|
||||
}
|
||||
className="pr-0"
|
||||
/>
|
||||
))}
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||||
{emailFields.map((email, index) => (
|
||||
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -35,6 +38,18 @@ export const loader = createLoaderApiRoute(
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
// Include error details for PARTIAL_FAILED batches
|
||||
successfulRunCount: batch.successfulRunCount ?? undefined,
|
||||
failedRunCount: batch.failedRunCount ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
body: BodySchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: () => ({}),
|
||||
superScopes: ["write:runs", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, body, authentication }) => {
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
try {
|
||||
const result = await service.call(
|
||||
params.key,
|
||||
body.taskIdentifier,
|
||||
authentication.environment
|
||||
);
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: error.status ?? 400 });
|
||||
}
|
||||
|
||||
logger.error("Failed to reset idempotency key via API", {
|
||||
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
|
||||
});
|
||||
|
||||
return json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
@@ -18,6 +18,9 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
errors: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
@@ -33,8 +36,21 @@ export const loader = createLoaderApiRoute(
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
processingCompletedAt: batch.processingCompletedAt ?? undefined,
|
||||
runCount: batch.runCount,
|
||||
runs: batch.runIds,
|
||||
processing: {
|
||||
completedAt: batch.processingCompletedAt ?? undefined,
|
||||
errors:
|
||||
batch.errors.length > 0
|
||||
? batch.errors.map((err) => ({
|
||||
index: err.index,
|
||||
taskIdentifier: err.taskIdentifier,
|
||||
error: err.error,
|
||||
errorCode: err.errorCode ?? undefined,
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -110,6 +110,8 @@ const { action, loader } = createActionApiRoute(
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
// Note: SDK v4.3+ uses the 2-phase batch API (POST /api/v3/batches + streaming items)
|
||||
// This endpoint is for backwards compatibility with older SDK versions
|
||||
const service = new RunEngineBatchTriggerService(batchProcessingStrategy ?? undefined);
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
StreamBatchItemsService,
|
||||
createNdjsonParserStream,
|
||||
streamToAsyncIterable,
|
||||
} from "~/runEngine/services/streamBatchItems.server";
|
||||
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 2 of 2-phase batch API: Stream batch items.
|
||||
*
|
||||
* POST /api/v3/batches/:batchId/items
|
||||
*
|
||||
* Accepts an NDJSON stream of batch items and enqueues them to the BatchQueue.
|
||||
* Each line in the body should be a valid BatchItemNDJSON object.
|
||||
*
|
||||
* The stream is processed with backpressure - items are enqueued as they arrive.
|
||||
* The batch is sealed when the stream completes successfully.
|
||||
*/
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Validate params
|
||||
const paramsResult = ParamsSchema.safeParse(params);
|
||||
if (!paramsResult.success) {
|
||||
return json({ error: "Invalid batch ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { batchId } = paramsResult.data;
|
||||
|
||||
// Validate content type
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (
|
||||
!contentType.includes("application/x-ndjson") &&
|
||||
!contentType.includes("application/ndjson")
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error: "Content-Type must be application/x-ndjson or application/ndjson",
|
||||
},
|
||||
{ status: 415 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
const authResult = await authenticateApiRequestWithFailure(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
|
||||
if (!authResult.ok) {
|
||||
return json({ error: authResult.error }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get the request body stream
|
||||
const body = request.body;
|
||||
if (!body) {
|
||||
return json({ error: "Request body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Stream batch items request", {
|
||||
batchId,
|
||||
contentType,
|
||||
envId: authResult.environment.id,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create NDJSON parser transform stream
|
||||
const parser = createNdjsonParserStream(env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE);
|
||||
|
||||
// Pipe the request body through the parser
|
||||
const parsedStream = body.pipeThrough(parser);
|
||||
|
||||
// Convert to async iterable for the service
|
||||
const itemsIterator = streamToAsyncIterable(parsedStream);
|
||||
|
||||
// Process the stream
|
||||
const service = new StreamBatchItemsService();
|
||||
const result = await service.call(authResult.environment, batchId, itemsIterator, {
|
||||
maxItemBytes: env.STREAMING_BATCH_ITEM_MAXIMUM_SIZE,
|
||||
});
|
||||
|
||||
return json(result, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("Stream batch items error", {
|
||||
batchId,
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
// Check for stream parsing errors
|
||||
if (
|
||||
error.message.includes("Invalid JSON") ||
|
||||
error.message.includes("exceeds maximum size")
|
||||
) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Return 405 for GET requests - only POST is allowed
|
||||
return json(
|
||||
{
|
||||
error: "Method not allowed. Use POST to stream batch items.",
|
||||
},
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { CreateBatchRequestBody, CreateBatchResponse, generateJWT } from "@trigger.dev/core/v3";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { BatchRateLimitExceededError } from "~/runEngine/concerns/batchLimits.server";
|
||||
import { CreateBatchService } from "~/runEngine/services/createBatch.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
handleRequestIdempotency,
|
||||
saveRequestIdempotency,
|
||||
} from "~/utils/requestIdempotency.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
/**
|
||||
* Phase 1 of 2-phase batch API: Create a batch.
|
||||
*
|
||||
* POST /api/v3/batches
|
||||
*
|
||||
* Creates a batch record and optionally blocks the parent run for batchTriggerAndWait.
|
||||
* Items are streamed separately via POST /api/v3/batches/:batchId/items
|
||||
*/
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
body: CreateBatchRequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
|
||||
authorization: {
|
||||
action: "batchTrigger",
|
||||
resource: () => ({
|
||||
// No specific tasks to authorize at batch creation time
|
||||
// Tasks are validated when items are streamed
|
||||
tasks: [],
|
||||
}),
|
||||
superScopes: ["write:tasks", "admin"],
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ body, headers, authentication }) => {
|
||||
// Validate runCount
|
||||
if (body.runCount <= 0) {
|
||||
return json({ error: "runCount must be a positive integer" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check runCount against limit
|
||||
if (body.runCount > env.STREAMING_BATCH_MAX_ITEMS) {
|
||||
return json(
|
||||
{
|
||||
error: `Batch runCount of ${body.runCount} exceeds maximum allowed of ${env.STREAMING_BATCH_MAX_ITEMS}.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
"trigger-version": triggerVersion,
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
|
||||
const oneTimeUseToken = await getOneTimeUseToken(authentication);
|
||||
|
||||
logger.debug("Create batch request", {
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
triggerVersion,
|
||||
isFromWorker,
|
||||
triggerClient,
|
||||
});
|
||||
|
||||
// Handle idempotency for the batch creation
|
||||
const cachedResponse = await handleRequestIdempotency<
|
||||
{ friendlyId: string; runCount: number },
|
||||
CreateBatchResponse
|
||||
>(body.idempotencyKey, {
|
||||
requestType: "create-batch",
|
||||
findCachedEntity: async (cachedRequestId) => {
|
||||
return await prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: cachedRequestId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
runCount: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
buildResponse: (cachedBatch) => ({
|
||||
id: cachedBatch.friendlyId,
|
||||
runCount: cachedBatch.runCount,
|
||||
isCached: true,
|
||||
}),
|
||||
buildResponseHeaders: async (responseBody) => {
|
||||
return await responseHeaders(responseBody, authentication.environment, triggerClient);
|
||||
},
|
||||
});
|
||||
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const traceContext = isFromWorker
|
||||
? { traceparent, tracestate }
|
||||
: { external: { traceparent, tracestate } };
|
||||
|
||||
const service = new CreateBatchService();
|
||||
|
||||
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
|
||||
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
|
||||
});
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
realtimeStreamsVersion: determineRealtimeStreamsVersion(
|
||||
realtimeStreamsVersion ?? undefined
|
||||
),
|
||||
});
|
||||
|
||||
const $responseHeaders = await responseHeaders(
|
||||
batch,
|
||||
authentication.environment,
|
||||
triggerClient
|
||||
);
|
||||
|
||||
return json(batch, {
|
||||
status: 202,
|
||||
headers: $responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BatchRateLimitExceededError) {
|
||||
logger.info("Batch rate limit exceeded", {
|
||||
limit: error.limit,
|
||||
remaining: error.remaining,
|
||||
resetAt: error.resetAt.toISOString(),
|
||||
itemCount: error.itemCount,
|
||||
});
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": error.limit.toString(),
|
||||
"X-RateLimit-Remaining": Math.max(0, error.remaining).toString(),
|
||||
"X-RateLimit-Reset": Math.floor(error.resetAt.getTime() / 1000).toString(),
|
||||
"Retry-After": Math.max(
|
||||
1,
|
||||
Math.ceil((error.resetAt.getTime() - Date.now()) / 1000)
|
||||
).toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Create batch error", {
|
||||
error: {
|
||||
message: (error as Error).message,
|
||||
stack: (error as Error).stack,
|
||||
},
|
||||
});
|
||||
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof OutOfEntitlementError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json(
|
||||
{ error: error.message },
|
||||
{ status: 500, headers: { "x-should-retry": "false" } }
|
||||
);
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function responseHeaders(
|
||||
batch: CreateBatchResponse,
|
||||
environment: AuthenticatedEnvironment,
|
||||
triggerClient?: string | null
|
||||
): Promise<Record<string, string>> {
|
||||
const claimsHeader = JSON.stringify({
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
});
|
||||
|
||||
if (triggerClient === "browser") {
|
||||
const claims = {
|
||||
sub: environment.id,
|
||||
pub: true,
|
||||
scopes: [`read:batch:${batch.id}`, `write:batch:${batch.id}`],
|
||||
};
|
||||
|
||||
const jwt = await generateJWT({
|
||||
secretKey: extractJwtSigningSecretKey(environment),
|
||||
payload: claims,
|
||||
expirationTime: "1h",
|
||||
});
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
"x-trigger-jwt": jwt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"x-trigger-jwt-claims": claimsHeader,
|
||||
};
|
||||
}
|
||||
|
||||
export { action, loader };
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { AIQueryService } from "~/v3/services/aiQueryService.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
|
||||
const RequestSchema = z.object({
|
||||
prompt: z.string().min(1, "Prompt is required"),
|
||||
mode: z.enum(["new", "edit"]).default("new"),
|
||||
currentQuery: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
// Parse the request body
|
||||
const formData = await request.formData();
|
||||
const submission = RequestSchema.safeParse(Object.fromEntries(formData));
|
||||
|
||||
if (!submission.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "Invalid request data",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "Project not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "Environment not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "OpenAI API key is not configured",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, mode, currentQuery } = submission.data;
|
||||
|
||||
const service = new AIQueryService(
|
||||
querySchemas,
|
||||
openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini")
|
||||
);
|
||||
|
||||
// Create a streaming response
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sendEvent = (event: {
|
||||
type: string;
|
||||
content?: string;
|
||||
tool?: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
query?: string;
|
||||
error?: string;
|
||||
}) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
};
|
||||
|
||||
try {
|
||||
const result = service.streamQuery(prompt, { mode, currentQuery });
|
||||
|
||||
// Process the stream
|
||||
for await (const part of result.fullStream) {
|
||||
switch (part.type) {
|
||||
case "text-delta": {
|
||||
sendEvent({ type: "thinking", content: part.textDelta });
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
sendEvent({
|
||||
type: "tool_call",
|
||||
tool: part.toolName,
|
||||
args: part.args,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "error": {
|
||||
sendEvent({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: part.error instanceof Error ? part.error.message : String(part.error),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "finish": {
|
||||
// Extract query from the final text
|
||||
const finalText = await result.text;
|
||||
const query = extractQueryFromText(finalText);
|
||||
|
||||
if (query) {
|
||||
sendEvent({
|
||||
type: "result",
|
||||
success: true,
|
||||
query,
|
||||
});
|
||||
} else if (
|
||||
finalText.toLowerCase().includes("cannot") ||
|
||||
finalText.toLowerCase().includes("unable")
|
||||
) {
|
||||
sendEvent({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: finalText.slice(0, 300),
|
||||
});
|
||||
} else {
|
||||
sendEvent({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: "Could not generate a valid query",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sendEvent({
|
||||
type: "result",
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "An error occurred",
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a SQL query from the AI response text
|
||||
*/
|
||||
function extractQueryFromText(text: string): string | null {
|
||||
// Try to extract from code block first
|
||||
const codeBlockMatch = text.match(/```(?:sql)?\s*([\s\S]*?)```/i);
|
||||
if (codeBlockMatch) {
|
||||
return codeBlockMatch[1].trim();
|
||||
}
|
||||
|
||||
// Try to find a SELECT statement
|
||||
const selectMatch = text.match(/SELECT[\s\S]+?(?:LIMIT\s+\d+|;|$)/i);
|
||||
if (selectMatch) {
|
||||
return selectMatch[0].trim().replace(/;$/, "");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { type ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { jsonWithErrorMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
|
||||
import { v3RunParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const resetIdempotencyKeySchema = z.object({
|
||||
taskIdentifier: z.string().min(1, "Task identifier is required"),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, runParam } =
|
||||
v3RunParamsSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const { taskIdentifier } = submission.value;
|
||||
|
||||
const taskRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
project: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: {
|
||||
slug: envParam,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
idempotencyKey: true,
|
||||
taskIdentifier: true,
|
||||
runtimeEnvironmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
submission.error = { runParam: ["Run not found"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!taskRun.idempotencyKey) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"This run does not have an idempotency key"
|
||||
);
|
||||
}
|
||||
|
||||
if (taskRun.taskIdentifier !== taskIdentifier) {
|
||||
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
where: {
|
||||
id: taskRun.runtimeEnvironmentId,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
"Environment not found"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new ResetIdempotencyKeyService();
|
||||
|
||||
await service.call(taskRun.idempotencyKey, taskIdentifier, {
|
||||
...environment,
|
||||
organizationId: environment.project.organizationId,
|
||||
organization: environment.project.organization,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to reset idempotency key", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${error.message}`
|
||||
);
|
||||
} else {
|
||||
logger.error("Failed to reset idempotency key", { error });
|
||||
return jsonWithErrorMessage(
|
||||
submission,
|
||||
request,
|
||||
`Failed to reset idempotency key: ${JSON.stringify(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
+68
-8
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
CloudArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
@@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { ToastUI } from "~/components/primitives/Toast";
|
||||
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
v3BatchPath,
|
||||
v3DeploymentVersionPath,
|
||||
v3RunDownloadLogsPath,
|
||||
v3RunIdempotencyKeyResetPath,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
@@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -293,6 +298,28 @@ function RunBody({
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
|
||||
|
||||
// Handle toast messages from the reset action
|
||||
useEffect(() => {
|
||||
if (resetFetcher.data && resetFetcher.state === "idle") {
|
||||
// Check if the response indicates success
|
||||
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant="success"
|
||||
message="Idempotency key reset successfully"
|
||||
t={t as string}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [resetFetcher.data, resetFetcher.state]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
@@ -543,16 +570,49 @@ function RunBody({
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
|
||||
{run.idempotencyKey && (
|
||||
<div>
|
||||
Expires:{" "}
|
||||
{run.idempotencyKeyExpiresAt ? (
|
||||
<DateTime date={run.idempotencyKeyExpiresAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{run.idempotencyKey && (
|
||||
<resetFetcher.Form
|
||||
method="post"
|
||||
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
|
||||
>
|
||||
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
disabled={resetFetcher.state === "submitting"}
|
||||
>
|
||||
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
|
||||
</Button>
|
||||
</resetFetcher.Form>
|
||||
)}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Debounce</Property.Label>
|
||||
<Property.Value>
|
||||
{run.debounce ? (
|
||||
<div>
|
||||
<div className="break-all">Key: {run.debounce.key}</div>
|
||||
<div>Delay: {run.debounce.delay}</div>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { rootPath, v3RunPath } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runParam: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { runParam } = ParamsSchema.parse(params);
|
||||
|
||||
const run = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
project: {
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
slug: true,
|
||||
organization: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return redirectWithErrorMessage(
|
||||
rootPath(),
|
||||
request,
|
||||
"Run either doesn't exist or you don't have permission to view it",
|
||||
{
|
||||
ephemeral: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return redirect(
|
||||
v3RunPath(
|
||||
{ slug: run.project.organization.slug },
|
||||
{ slug: run.project.slug },
|
||||
{ slug: run.runtimeEnvironment.slug },
|
||||
{ friendlyId: runParam }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
CopyableTableCell,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -60,6 +61,33 @@ export default function Story() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header1>Copyable cells</Header1>
|
||||
<Paragraph>
|
||||
Hover over the first column to see the copy button. Click to copy the cell value.
|
||||
</Paragraph>
|
||||
<Table>
|
||||
<TableHeader className="bg-background-bright">
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID (copyable)</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }, (_, index) => {
|
||||
const id = `run_${crypto.randomUUID().slice(0, 8)}`;
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<CopyableTableCell value={id}>{id}</CopyableTableCell>
|
||||
<TableCell>Task {index + 1}</TableCell>
|
||||
<TableCell>Completed</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useState } from "react";
|
||||
import { TSQLEditor } from "~/components/code/TSQLEditor";
|
||||
import { column, type TableSchema } from "@internal/tsql";
|
||||
|
||||
const RUN_STATUSES = ["PENDING", "QUEUED", "EXECUTING", "COMPLETED", "FAILED", "CANCELED"] as const;
|
||||
const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
|
||||
|
||||
const runsSchema: TableSchema = {
|
||||
name: "runs",
|
||||
clickhouseName: "trigger_dev.task_runs_v2",
|
||||
description: "Task runs table - stores all task execution records",
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
columns: {
|
||||
id: { name: "id", ...column("String", { description: "Unique run identifier" }) },
|
||||
task_id: { name: "task_id", ...column("String", { description: "Task identifier" }) },
|
||||
status: {
|
||||
name: "status",
|
||||
...column("String", {
|
||||
description: "Run status",
|
||||
allowedValues: [...RUN_STATUSES],
|
||||
}),
|
||||
},
|
||||
created_at: {
|
||||
name: "created_at",
|
||||
...column("DateTime64", { description: "When the run was created" }),
|
||||
},
|
||||
started_at: {
|
||||
name: "started_at",
|
||||
...column("Nullable(DateTime64)", { description: "When the run started executing" }),
|
||||
},
|
||||
completed_at: {
|
||||
name: "completed_at",
|
||||
...column("Nullable(DateTime64)", { description: "When the run completed" }),
|
||||
},
|
||||
duration_ms: {
|
||||
name: "duration_ms",
|
||||
...column("Nullable(UInt64)", { description: "Run duration in milliseconds" }),
|
||||
},
|
||||
// Virtual column: computed from started_at and completed_at
|
||||
execution_duration: {
|
||||
name: "execution_duration",
|
||||
...column("Nullable(Int64)", {
|
||||
description: "Computed execution time in milliseconds (virtual column)",
|
||||
}),
|
||||
expression: "dateDiff('millisecond', started_at, completed_at)",
|
||||
},
|
||||
// Virtual column: duration in seconds for convenience
|
||||
duration_seconds: {
|
||||
name: "duration_seconds",
|
||||
...column("Float64", {
|
||||
description: "Duration in seconds (virtual column)",
|
||||
}),
|
||||
expression: "duration_ms / 1000.0",
|
||||
},
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
};
|
||||
|
||||
const logsSchema: TableSchema = {
|
||||
name: "logs",
|
||||
clickhouseName: "trigger_dev.task_events_v2",
|
||||
description: "Task logs and events",
|
||||
tenantColumns: {
|
||||
organizationId: "organization_id",
|
||||
projectId: "project_id",
|
||||
environmentId: "environment_id",
|
||||
},
|
||||
columns: {
|
||||
id: { name: "id", ...column("String", { description: "Event identifier" }) },
|
||||
run_id: { name: "run_id", ...column("String", { description: "Associated run ID" }) },
|
||||
level: {
|
||||
name: "level",
|
||||
...column("String", {
|
||||
description: "Log level",
|
||||
allowedValues: [...LOG_LEVELS],
|
||||
}),
|
||||
},
|
||||
message: { name: "message", ...column("String", { description: "Log message content" }) },
|
||||
timestamp: { name: "timestamp", ...column("DateTime64", { description: "Event timestamp" }) },
|
||||
organization_id: { name: "organization_id", ...column("String") },
|
||||
project_id: { name: "project_id", ...column("String") },
|
||||
environment_id: { name: "environment_id", ...column("String") },
|
||||
},
|
||||
};
|
||||
|
||||
const exampleSchema = [runsSchema, logsSchema];
|
||||
|
||||
const exampleQueries = [
|
||||
{
|
||||
name: "Simple SELECT",
|
||||
query: "SELECT * FROM runs LIMIT 10",
|
||||
},
|
||||
{
|
||||
name: "With WHERE clause",
|
||||
query: "SELECT id, task_id, status, created_at FROM runs WHERE status = 'COMPLETED' LIMIT 100",
|
||||
},
|
||||
{
|
||||
name: "Enum IN clause",
|
||||
query: "SELECT * FROM runs WHERE status IN ('PENDING', 'QUEUED', 'EXECUTING') LIMIT 50",
|
||||
},
|
||||
{
|
||||
name: "Virtual columns",
|
||||
query: `SELECT
|
||||
id,
|
||||
status,
|
||||
execution_duration,
|
||||
duration_seconds
|
||||
FROM runs
|
||||
WHERE execution_duration > 5000
|
||||
ORDER BY execution_duration DESC
|
||||
LIMIT 20`,
|
||||
},
|
||||
{
|
||||
name: "Aggregation",
|
||||
query: "SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY count DESC",
|
||||
},
|
||||
{
|
||||
name: "Join query",
|
||||
query: `SELECT
|
||||
runs.id,
|
||||
runs.status,
|
||||
logs.message,
|
||||
logs.level
|
||||
FROM runs
|
||||
JOIN logs ON runs.id = logs.run_id
|
||||
WHERE logs.level = 'ERROR'
|
||||
LIMIT 50`,
|
||||
},
|
||||
{
|
||||
name: "Date filtering",
|
||||
query: `SELECT
|
||||
toStartOfDay(created_at) as day,
|
||||
count(*) as runs_count,
|
||||
avg(duration_ms) as avg_duration
|
||||
FROM runs
|
||||
WHERE created_at > now() - INTERVAL 7 DAY
|
||||
GROUP BY day
|
||||
ORDER BY day DESC`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Story() {
|
||||
const [query, setQuery] = useState(exampleQueries[0].query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-text-bright">TSQL Editor</h1>
|
||||
<p className="text-text-dimmed">
|
||||
A CodeMirror-based SQL editor with syntax highlighting, schema-aware autocomplete, and
|
||||
real-time error detection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Example queries */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">Example Queries</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{exampleQueries.map((example) => (
|
||||
<button
|
||||
key={example.name}
|
||||
onClick={() => setQuery(example.query)}
|
||||
className="rounded bg-charcoal-700 px-3 py-1.5 text-sm text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
|
||||
>
|
||||
{example.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main editor */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">Editor with Schema</h2>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
Try typing to see autocomplete suggestions. Type <code>status = </code> to see enum value
|
||||
suggestions. Available tables: <code>runs</code>, <code>logs</code>
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue={query}
|
||||
onChange={setQuery}
|
||||
schema={exampleSchema}
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
showClearButton={true}
|
||||
minHeight="200px"
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only example */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">Read-only Mode</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue="SELECT id, status, created_at FROM runs WHERE status = 'FAILED' ORDER BY created_at DESC LIMIT 10"
|
||||
readOnly={true}
|
||||
schema={exampleSchema}
|
||||
linterEnabled={false}
|
||||
showCopyButton={true}
|
||||
showClearButton={false}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor without schema (no autocomplete) */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">Without Schema (Basic Mode)</h2>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
Editor without schema - still has SQL syntax highlighting and keyword completion.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue="SELECT * FROM my_table WHERE id = 1"
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error example */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">With Syntax Error</h2>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
The linter detects syntax errors and underlines them in red.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue="SELEC * FORM runs"
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invalid enum value example */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">With Invalid Enum Value</h2>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
The linter validates enum values against the schema. Try changing{" "}
|
||||
<code>'INVALID_STATUS'</code> to a valid status like <code>'COMPLETED'</code>.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue="SELECT * FROM runs WHERE status = 'INVALID_STATUS' LIMIT 10"
|
||||
schema={exampleSchema}
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unknown column example */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">With Unknown Column</h2>
|
||||
<p className="text-sm text-text-dimmed">
|
||||
The linter warns about unknown column names. Try changing <code>unknown_col</code> to a
|
||||
valid column like <code>status</code>.
|
||||
</p>
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<TSQLEditor
|
||||
defaultValue="SELECT id, unknown_col FROM runs LIMIT 10"
|
||||
schema={exampleSchema}
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available tables reference */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold text-text-bright">Available Schema</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{exampleSchema.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="rounded-lg border border-grid-dimmed bg-charcoal-800 p-4"
|
||||
>
|
||||
<h3 className="mb-1 font-mono text-sm font-semibold text-text-bright">
|
||||
{table.name}
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-text-dimmed">{table.description}</p>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(table.columns).map(([name, col]) => (
|
||||
<div key={name} className="flex flex-col gap-0.5 text-xs">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<code className={col.expression ? "text-purple-400" : "text-blue-400"}>
|
||||
{name}
|
||||
</code>
|
||||
<span className="text-charcoal-400">{col.type}</span>
|
||||
{col.expression && (
|
||||
<span className="rounded bg-purple-500/20 px-1 text-[10px] text-purple-300">
|
||||
virtual
|
||||
</span>
|
||||
)}
|
||||
{col.description && (
|
||||
<span className="text-text-dimmed">- {col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{col.allowedValues && col.allowedValues.length > 0 && (
|
||||
<div className="ml-4 text-green-400/70">
|
||||
Allowed: {col.allowedValues.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{col.expression && (
|
||||
<div className="ml-4 font-mono text-purple-400/70">
|
||||
Expression: {col.expression}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Fragment } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AppContainer } from "~/components/layout/AppLayout";
|
||||
import { env } from "~/env.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const stories: Story[] = [
|
||||
@@ -120,6 +120,10 @@ const stories: Story[] = [
|
||||
name: "Tree view",
|
||||
slug: "tree-view",
|
||||
},
|
||||
{
|
||||
name: "TSQL Editor",
|
||||
slug: "tsql-editor",
|
||||
},
|
||||
{
|
||||
name: "Timeline",
|
||||
slug: "timeline",
|
||||
@@ -177,11 +181,9 @@ const stories: Story[] = [
|
||||
];
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
|
||||
console.log("ENV", env.NODE_ENV);
|
||||
|
||||
if (env.NODE_ENV !== "development") {
|
||||
if (!user.admin) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import type { GlobalRateLimiter } from "@trigger.dev/redis-worker";
|
||||
import { RateLimiter } from "~/services/rateLimiter.server";
|
||||
|
||||
/**
|
||||
* Creates a global rate limiter for the batch queue that limits
|
||||
* the maximum number of items processed per second across all consumers.
|
||||
*
|
||||
* Uses a token bucket algorithm where:
|
||||
* - `itemsPerSecond` tokens are available per second
|
||||
* - The bucket can hold up to `itemsPerSecond` tokens (burst capacity)
|
||||
*
|
||||
* @param itemsPerSecond - Maximum items to process per second
|
||||
* @returns A GlobalRateLimiter compatible with FairQueue
|
||||
*/
|
||||
export function createBatchGlobalRateLimiter(itemsPerSecond: number): GlobalRateLimiter {
|
||||
const limiter = new RateLimiter({
|
||||
keyPrefix: "batch-queue-global",
|
||||
// Token bucket: refills `itemsPerSecond` tokens every second
|
||||
// Bucket capacity is also `itemsPerSecond` (allows burst up to limit)
|
||||
limiter: Ratelimit.tokenBucket(itemsPerSecond, "1 s", itemsPerSecond),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
async limit() {
|
||||
const result = await limiter.limit("global");
|
||||
return {
|
||||
allowed: result.success,
|
||||
resetAt: result.reset,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Organization } from "@trigger.dev/database";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { RateLimiterConfig } from "~/services/authorizationRateLimitMiddleware.server";
|
||||
import { createRedisRateLimitClient, Duration, RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
const BatchLimitsConfig = z.object({
|
||||
processingConcurrency: z.number().int().default(env.BATCH_CONCURRENCY_LIMIT_DEFAULT),
|
||||
});
|
||||
|
||||
/**
|
||||
* Batch limits configuration for a plan type
|
||||
*/
|
||||
export type BatchLimitsConfig = z.infer<typeof BatchLimitsConfig>;
|
||||
|
||||
const batchLimitsRedisClient = singleton("batchLimitsRedisClient", createBatchLimitsRedisClient);
|
||||
|
||||
function createBatchLimitsRedisClient() {
|
||||
const redisClient = createRedisRateLimitClient({
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
|
||||
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
|
||||
|
||||
const limiter =
|
||||
limiterConfig.type === "fixedWindow"
|
||||
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
|
||||
: limiterConfig.type === "tokenBucket"
|
||||
? Ratelimit.tokenBucket(
|
||||
limiterConfig.refillRate,
|
||||
limiterConfig.interval,
|
||||
limiterConfig.maxTokens
|
||||
)
|
||||
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
|
||||
|
||||
return new RateLimiter({
|
||||
redisClient: batchLimitsRedisClient,
|
||||
keyPrefix: "ratelimit:batch",
|
||||
limiter,
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
|
||||
const defaultRateLimiterConfig: RateLimiterConfig = {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.BATCH_RATE_LIMIT_MAX,
|
||||
};
|
||||
|
||||
if (!batchRateLimitConfig) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
const parsedBatchRateLimitConfig = RateLimiterConfig.safeParse(batchRateLimitConfig);
|
||||
|
||||
if (!parsedBatchRateLimitConfig.success) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
return parsedBatchRateLimitConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter and limits for an organization.
|
||||
* Internally looks up the plan type, but doesn't expose it to callers.
|
||||
*/
|
||||
export async function getBatchLimits(
|
||||
organization: Organization
|
||||
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
|
||||
const rateLimiter = createOrganizationRateLimiter(organization);
|
||||
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
|
||||
return { rateLimiter, config };
|
||||
}
|
||||
|
||||
function resolveBatchLimitsConfig(batchLimitsConfig?: unknown): BatchLimitsConfig {
|
||||
const defaultLimitsConfig: BatchLimitsConfig = {
|
||||
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
if (!batchLimitsConfig) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
const parsedBatchLimitsConfig = BatchLimitsConfig.safeParse(batchLimitsConfig);
|
||||
|
||||
if (!parsedBatchLimitsConfig.success) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
return parsedBatchLimitsConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when batch rate limit is exceeded.
|
||||
* Contains information for constructing a proper 429 response.
|
||||
*/
|
||||
export class BatchRateLimitExceededError extends Error {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
public readonly remaining: number,
|
||||
public readonly resetAt: Date,
|
||||
public readonly itemCount: number
|
||||
) {
|
||||
super(`Batch rate limit exceeded. Limit resets at ${resetAt.toISOString()}`);
|
||||
this.name = "BatchRateLimitExceededError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { uploadPacketToObjectStore, r2 } from "~/v3/r2.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export type BatchPayloadProcessResult = {
|
||||
/** The processed payload - either the original or an R2 path */
|
||||
payload: unknown;
|
||||
/** The payload type - "application/store" if offloaded to R2 */
|
||||
payloadType: string;
|
||||
/** Whether the payload was offloaded to R2 */
|
||||
wasOffloaded: boolean;
|
||||
/** Size of the payload in bytes */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BatchPayloadProcessor handles payload offloading for batch items.
|
||||
*
|
||||
* When a batch item's payload exceeds the configured threshold, it's uploaded
|
||||
* to object storage (R2) and the payload is replaced with the storage path.
|
||||
* This aligns with how single task triggers work via DefaultPayloadProcessor.
|
||||
*
|
||||
* Path format: batch_{batchId}/item_{index}/payload.json
|
||||
*/
|
||||
export class BatchPayloadProcessor {
|
||||
/**
|
||||
* Check if object storage is available for payload offloading.
|
||||
* If not available, large payloads will be stored inline (which may fail for very large payloads).
|
||||
*/
|
||||
isObjectStoreAvailable(): boolean {
|
||||
return r2 !== undefined && env.OBJECT_STORE_BASE_URL !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json")
|
||||
* @param batchId - The batch ID (internal format)
|
||||
* @param itemIndex - The item index within the batch
|
||||
* @param environment - The authenticated environment for R2 path construction
|
||||
* @returns The processed result with potentially offloaded payload
|
||||
*/
|
||||
async process(
|
||||
payload: unknown,
|
||||
payloadType: string,
|
||||
batchId: string,
|
||||
itemIndex: number,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<BatchPayloadProcessResult> {
|
||||
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
|
||||
span.setAttribute("batchId", batchId);
|
||||
span.setAttribute("itemIndex", itemIndex);
|
||||
span.setAttribute("payloadType", payloadType);
|
||||
|
||||
// Create the packet for size checking
|
||||
const packet = this.#createPayloadPacket(payload, payloadType);
|
||||
|
||||
if (!packet.data) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
|
||||
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
|
||||
|
||||
span.setAttribute("payloadSize", size);
|
||||
span.setAttribute("needsOffloading", needsOffloading);
|
||||
span.setAttribute("threshold", threshold);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if object store is available
|
||||
if (!this.isObjectStoreAvailable()) {
|
||||
logger.warn("Payload exceeds threshold but object store is not available", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
size,
|
||||
threshold,
|
||||
});
|
||||
|
||||
// Return without offloading - the payload will be stored inline
|
||||
// This may fail downstream for very large payloads
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload to R2
|
||||
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
|
||||
|
||||
const [uploadError] = await tryCatch(
|
||||
uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
logger.error("Failed to upload batch item payload to object store", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
error: uploadError instanceof Error ? uploadError.message : String(uploadError),
|
||||
});
|
||||
|
||||
// Throw to fail this item - SDK can retry
|
||||
throw new Error(
|
||||
`Failed to upload large payload to object store: ${
|
||||
uploadError instanceof Error ? uploadError.message : String(uploadError)
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Batch item payload offloaded to R2", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
filename,
|
||||
size,
|
||||
});
|
||||
|
||||
span.setAttribute("wasOffloaded", true);
|
||||
span.setAttribute("offloadPath", filename);
|
||||
|
||||
return {
|
||||
payload: filename,
|
||||
payloadType: "application/store",
|
||||
wasOffloaded: true,
|
||||
size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOPacket from payload for size checking.
|
||||
*/
|
||||
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
|
||||
if (payloadType === "application/json") {
|
||||
// Payload from SDK is already serialized as a string - use directly
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: "application/json" };
|
||||
}
|
||||
// Non-string payloads (e.g., direct API calls with objects) need serialization
|
||||
return { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: payloadType };
|
||||
}
|
||||
|
||||
// For other types, try to stringify
|
||||
try {
|
||||
return { data: JSON.stringify(payload), dataType: payloadType };
|
||||
} catch {
|
||||
return { dataType: payloadType };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,35 @@ import { env } from "~/env.server";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
/**
|
||||
* Extract the queue name from a queue option that may be:
|
||||
* - An object with a string `name` property: { name: "queue-name" }
|
||||
* - A double-wrapped object (bug case): { name: { name: "queue-name", ... } }
|
||||
*
|
||||
* This handles the case where the SDK accidentally double-wraps the queue
|
||||
* option when it's already an object with a name property.
|
||||
*/
|
||||
function extractQueueName(queue: { name?: unknown } | undefined): string | undefined {
|
||||
if (!queue?.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normal case: queue.name is a string
|
||||
if (typeof queue.name === "string") {
|
||||
return queue.name;
|
||||
}
|
||||
|
||||
// Double-wrapped case: queue.name is an object with its own name property
|
||||
if (typeof queue.name === "object" && queue.name !== null && "name" in queue.name) {
|
||||
const innerName = (queue.name as { name: unknown }).name;
|
||||
if (typeof innerName === "string") {
|
||||
return innerName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class DefaultQueueManager implements QueueManager {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
@@ -32,8 +61,8 @@ export class DefaultQueueManager implements QueueManager {
|
||||
// Determine queue name based on lockToVersion and provided options
|
||||
if (lockedBackgroundWorker) {
|
||||
// Task is locked to a specific worker version
|
||||
if (request.body.options?.queue?.name) {
|
||||
const specifiedQueueName = request.body.options.queue.name;
|
||||
const specifiedQueueName = extractQueueName(request.body.options?.queue);
|
||||
if (specifiedQueueName) {
|
||||
// A specific queue name is provided
|
||||
const specifiedQueue = await this.prisma.taskQueue.findFirst({
|
||||
// Validate it exists for the locked worker
|
||||
@@ -126,8 +155,10 @@ export class DefaultQueueManager implements QueueManager {
|
||||
const { taskId, environment, body } = request;
|
||||
const { queue } = body.options ?? {};
|
||||
|
||||
if (queue?.name) {
|
||||
return queue.name;
|
||||
// Use extractQueueName to handle double-wrapped queue objects
|
||||
const queueName = extractQueueName(queue);
|
||||
if (queueName) {
|
||||
return queueName;
|
||||
}
|
||||
|
||||
const defaultQueueName = `task/${taskId}`;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { autoIncrementCounter } from "~/services/autoIncrementCounter.server";
|
||||
import { RunNumberIncrementer, TriggerTaskRequest } from "../types";
|
||||
|
||||
export class DefaultRunNumberIncrementer implements RunNumberIncrementer {
|
||||
async incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
return await autoIncrementCounter.incrementInTransaction(
|
||||
`v3-run:${request.environment.id}:${request.taskId}`,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
@@ -116,6 +117,73 @@ export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { existingRun, debounceKey, incomplete, isError } = options;
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
`${request.taskId} (debounced)`,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
||||
},
|
||||
style: {
|
||||
icon: "task-cached",
|
||||
},
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
incomplete,
|
||||
isError,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
// Log a message about the debounced trigger
|
||||
await repository.recordEvent(
|
||||
`Debounced: using existing run with key "${debounceKey}"`,
|
||||
{
|
||||
taskSlug: request.taskId,
|
||||
environment: request.environment,
|
||||
attributes: {
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
context: request.options?.traceContext,
|
||||
parentId: event.spanId,
|
||||
}
|
||||
);
|
||||
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { InitializeBatchOptions } from "@internal/run-engine";
|
||||
import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3";
|
||||
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
||||
import { Evt } from "evt";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchRateLimitExceededError, getBatchLimits } from "../concerns/batchLimits.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
|
||||
|
||||
export type CreateBatchServiceOptions = {
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
|
||||
spanParentAsLink?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
realtimeStreamsVersion?: "v1" | "v2";
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Batch Service (Phase 1 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 1 of the streaming batch API:
|
||||
* 1. Validates entitlement and queue limits
|
||||
* 2. Creates BatchTaskRun in Postgres with status=PENDING, expectedCount set
|
||||
* 3. For batchTriggerAndWait: blocks the parent run immediately
|
||||
* 4. Initializes batch metadata in Redis
|
||||
* 5. Returns batch ID - items are streamed separately via Phase 2
|
||||
*
|
||||
* The batch is NOT sealed until Phase 2 completes.
|
||||
*/
|
||||
export class CreateBatchService extends WithRunEngine {
|
||||
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
||||
private readonly queueConcern: DefaultQueueManager;
|
||||
private readonly validator: DefaultTriggerTaskValidator;
|
||||
|
||||
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
|
||||
super({ prisma: _prisma });
|
||||
|
||||
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine);
|
||||
this.validator = new DefaultTriggerTaskValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a batch for 2-phase processing.
|
||||
* Items will be streamed separately via the StreamBatchItemsService.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: CreateBatchRequestBody,
|
||||
options: CreateBatchServiceOptions = {}
|
||||
): Promise<CreateBatchResponse> {
|
||||
try {
|
||||
return await this.traceWithEnv<CreateBatchResponse>(
|
||||
"createBatch()",
|
||||
environment,
|
||||
async (span) => {
|
||||
const { id, friendlyId } = BatchId.generate();
|
||||
|
||||
span.setAttribute("batchId", friendlyId);
|
||||
span.setAttribute("runCount", body.runCount);
|
||||
|
||||
// Validate entitlement
|
||||
const entitlementValidation = await this.validator.validateEntitlement({
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!entitlementValidation.ok) {
|
||||
throw entitlementValidation.error;
|
||||
}
|
||||
|
||||
// Extract plan type from entitlement validation for billing tracking
|
||||
const planType = entitlementValidation.plan?.type;
|
||||
|
||||
// Get batch limits for this organization
|
||||
const { config, rateLimiter } = await getBatchLimits(environment.organization);
|
||||
|
||||
// Check rate limit BEFORE creating the batch
|
||||
// This prevents burst creation of batches that exceed the rate limit
|
||||
const rateResult = await rateLimiter.limit(environment.id, body.runCount);
|
||||
|
||||
if (!rateResult.success) {
|
||||
throw new BatchRateLimitExceededError(
|
||||
rateResult.limit,
|
||||
rateResult.remaining,
|
||||
new Date(rateResult.reset),
|
||||
body.runCount
|
||||
);
|
||||
}
|
||||
|
||||
// Validate queue limits for the expected batch size
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
body.runCount
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot create batch with ${body.runCount} items as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create BatchTaskRun in Postgres with PENDING status
|
||||
// The batch will be sealed (status -> PROCESSING) when items are streamed
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
id,
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
status: "PENDING",
|
||||
runCount: body.runCount,
|
||||
expectedCount: body.runCount,
|
||||
runIds: [],
|
||||
batchVersion: "runengine:v2", // 2-phase streaming batch API
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
// Not sealed yet - will be sealed when items stream completes
|
||||
sealed: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.onBatchTaskRunCreated.post(batch);
|
||||
|
||||
// Block parent run if this is a batchTriggerAndWait
|
||||
if (body.parentRunId && body.resumeParentOnCompletion) {
|
||||
await this._engine.blockRunWithCreatedBatch({
|
||||
runId: RunId.fromFriendlyId(body.parentRunId),
|
||||
batchId: batch.id,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
organizationId: environment.organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize batch metadata in Redis (without items)
|
||||
const initOptions: InitializeBatchOptions = {
|
||||
batchId: id,
|
||||
friendlyId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
runCount: body.runCount,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
planType,
|
||||
};
|
||||
|
||||
await this._engine.initializeBatch(initOptions);
|
||||
|
||||
logger.info("Batch created", {
|
||||
batchId: friendlyId,
|
||||
runCount: body.runCount,
|
||||
envId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
processingConcurrency: config.processingConcurrency,
|
||||
});
|
||||
|
||||
return {
|
||||
id: friendlyId,
|
||||
runCount: body.runCount,
|
||||
isCached: false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Handle Prisma unique constraint violations
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("CreateBatchService: Prisma error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
meta: error.meta,
|
||||
});
|
||||
|
||||
if (error.code === "P2002") {
|
||||
const target = error.meta?.target;
|
||||
|
||||
if (
|
||||
Array.isArray(target) &&
|
||||
target.length > 0 &&
|
||||
typeof target[0] === "string" &&
|
||||
target[0].includes("oneTimeUseToken")
|
||||
) {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch with a one-time use token as it has already been used."
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot create batch as it has already been created with the same idempotency key."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
type BatchItemNDJSON,
|
||||
type StreamBatchItemsResponse,
|
||||
BatchItemNDJSON as BatchItemNDJSONSchema,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BatchItem, RunEngine } from "@internal/run-engine";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
|
||||
|
||||
export type StreamBatchItemsServiceOptions = {
|
||||
maxItemBytes: number;
|
||||
};
|
||||
|
||||
export type StreamBatchItemsServiceConstructorOptions = {
|
||||
prisma?: PrismaClientOrTransaction;
|
||||
engine?: RunEngine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 2 of the streaming batch API:
|
||||
* 1. Validates batch exists and is in PENDING status
|
||||
* 2. Processes NDJSON stream item by item
|
||||
* 3. Calls engine.enqueueBatchItem() for each item
|
||||
* 4. Tracks accepted/deduplicated counts
|
||||
* 5. On completion: validates count, seals the batch
|
||||
*
|
||||
* The service is designed for streaming and processes items as they arrive,
|
||||
* providing backpressure through the async iterator pattern.
|
||||
*/
|
||||
export class StreamBatchItemsService extends WithRunEngine {
|
||||
private readonly payloadProcessor: BatchPayloadProcessor;
|
||||
|
||||
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
|
||||
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
|
||||
this.payloadProcessor = new BatchPayloadProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a batch friendly ID to its internal ID format.
|
||||
* Throws a ServiceValidationError with 400 status if the ID is malformed.
|
||||
*/
|
||||
private parseBatchFriendlyId(friendlyId: string): string {
|
||||
try {
|
||||
return BatchId.fromFriendlyId(friendlyId);
|
||||
} catch {
|
||||
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream of batch items from an async iterator.
|
||||
* Each item is validated and enqueued to the BatchQueue.
|
||||
* The batch is sealed when the stream completes.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
batchFriendlyId: string,
|
||||
itemsIterator: AsyncIterable<unknown>,
|
||||
options: StreamBatchItemsServiceOptions
|
||||
): Promise<StreamBatchItemsResponse> {
|
||||
return this.traceWithEnv<StreamBatchItemsResponse>(
|
||||
"streamBatchItems()",
|
||||
environment,
|
||||
async (span) => {
|
||||
span.setAttribute("batchId", batchFriendlyId);
|
||||
|
||||
// Convert friendly ID to internal ID
|
||||
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
|
||||
|
||||
// Validate batch exists and belongs to this environment
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: {
|
||||
id: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
runCount: true,
|
||||
sealed: true,
|
||||
batchVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batch) {
|
||||
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
|
||||
}
|
||||
|
||||
if (batch.sealed) {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is already sealed and cannot accept more items`
|
||||
);
|
||||
}
|
||||
|
||||
if (batch.status !== "PENDING") {
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
|
||||
);
|
||||
}
|
||||
|
||||
let itemsAccepted = 0;
|
||||
let itemsDeduplicated = 0;
|
||||
let lastIndex = -1;
|
||||
|
||||
// Process items from the stream
|
||||
for await (const rawItem of itemsIterator) {
|
||||
// Parse and validate the item
|
||||
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
|
||||
if (!parseResult.success) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid item at index ${lastIndex + 1}: ${parseResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const item = parseResult.data;
|
||||
lastIndex = item.index;
|
||||
|
||||
// Validate index is within expected range
|
||||
if (item.index >= batch.runCount) {
|
||||
throw new ServiceValidationError(
|
||||
`Item index ${item.index} exceeds batch runCount ${batch.runCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the original payload type
|
||||
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
|
||||
|
||||
// Process payload - offload to R2 if it exceeds threshold
|
||||
const processedPayload = await this.payloadProcessor.process(
|
||||
item.payload,
|
||||
originalPayloadType,
|
||||
batchId,
|
||||
item.index,
|
||||
environment
|
||||
);
|
||||
|
||||
// Convert to BatchItem format with potentially offloaded payload
|
||||
const batchItem: BatchItem = {
|
||||
task: item.task,
|
||||
payload: processedPayload.payload,
|
||||
payloadType: processedPayload.payloadType,
|
||||
options: item.options,
|
||||
};
|
||||
|
||||
// Enqueue the item
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
item.index,
|
||||
batchItem
|
||||
);
|
||||
|
||||
if (result.enqueued) {
|
||||
itemsAccepted++;
|
||||
} else {
|
||||
itemsDeduplicated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual enqueued count from Redis
|
||||
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
|
||||
|
||||
// Validate we received the expected number of items
|
||||
if (enqueuedCount !== batch.runCount) {
|
||||
logger.warn("Batch item count mismatch", {
|
||||
batchId: batchFriendlyId,
|
||||
expected: batch.runCount,
|
||||
received: enqueuedCount,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
});
|
||||
|
||||
// Don't seal the batch if count doesn't match
|
||||
// Return sealed: false so client knows to retry with missing items
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: false,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Seal the batch - use conditional update to prevent TOCTOU race
|
||||
// Another concurrent request may have already sealed this batch
|
||||
const now = new Date();
|
||||
const sealResult = await this._prisma.batchTaskRun.updateMany({
|
||||
where: {
|
||||
id: batchId,
|
||||
sealed: false,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: {
|
||||
sealed: true,
|
||||
sealedAt: now,
|
||||
status: "PROCESSING",
|
||||
processingStartedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if we won the race to seal the batch
|
||||
if (sealResult.count === 0) {
|
||||
// Another request sealed the batch first - re-query to check current state
|
||||
const currentBatch = await this._prisma.batchTaskRun.findUnique({
|
||||
where: { id: batchId },
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
sealed: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentBatch?.sealed && currentBatch.status === "PROCESSING") {
|
||||
// The batch was sealed by another request - this is fine, the goal was achieved
|
||||
logger.info("Batch already sealed by concurrent request", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
span.setAttribute("sealedByConcurrentRequest", true);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch is in an unexpected state - fail with error
|
||||
const actualStatus = currentBatch?.status ?? "unknown";
|
||||
const actualSealed = currentBatch?.sealed ?? "unknown";
|
||||
logger.error("Batch seal race condition: unexpected state", {
|
||||
batchId: batchFriendlyId,
|
||||
expectedStatus: "PENDING",
|
||||
actualStatus,
|
||||
expectedSealed: false,
|
||||
actualSealed,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Batch sealed and ready for processing", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
totalEnqueued: enqueuedCount,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NDJSON parser transform stream.
|
||||
*
|
||||
* Converts a stream of Uint8Array chunks into parsed JSON objects.
|
||||
* Each line in the NDJSON is parsed independently.
|
||||
*
|
||||
* Uses byte-buffer accumulation to:
|
||||
* - Prevent OOM from unbounded string buffers
|
||||
* - Properly handle multibyte UTF-8 characters across chunk boundaries
|
||||
* - Check size limits on raw bytes before decoding
|
||||
*
|
||||
* @param maxItemBytes - Maximum allowed bytes per line (item)
|
||||
* @returns TransformStream that outputs parsed JSON objects
|
||||
*/
|
||||
export function createNdjsonParserStream(
|
||||
maxItemBytes: number
|
||||
): TransformStream<Uint8Array, unknown> {
|
||||
// Single decoder instance, reused for all lines
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
// Byte buffer: array of chunks with tracked total length
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let lineNumber = 0;
|
||||
|
||||
const NEWLINE_BYTE = 0x0a; // '\n'
|
||||
|
||||
/**
|
||||
* Concatenate all chunks into a single Uint8Array
|
||||
*/
|
||||
function concatenateChunks(): Uint8Array {
|
||||
if (chunks.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunks.length === 1) {
|
||||
return chunks[0];
|
||||
}
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first newline byte in the buffer.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
function findNewlineIndex(): number {
|
||||
let globalIndex = 0;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.byteLength; i++) {
|
||||
if (chunk[i] === NEWLINE_BYTE) {
|
||||
return globalIndex + i;
|
||||
}
|
||||
}
|
||||
globalIndex += chunk.byteLength;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bytes from the buffer up to (but not including) the given index,
|
||||
* and remove those bytes plus the delimiter from the buffer.
|
||||
*/
|
||||
function extractLine(newlineIndex: number): Uint8Array {
|
||||
const fullBuffer = concatenateChunks();
|
||||
const lineBytes = fullBuffer.slice(0, newlineIndex);
|
||||
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
|
||||
|
||||
// Reset buffer with remaining bytes
|
||||
if (remaining.byteLength > 0) {
|
||||
chunks = [remaining];
|
||||
totalBytes = remaining.byteLength;
|
||||
} else {
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
}
|
||||
|
||||
return lineBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line from bytes, handling whitespace trimming.
|
||||
* Returns the parsed object or null for empty lines.
|
||||
*/
|
||||
function parseLine(
|
||||
lineBytes: Uint8Array,
|
||||
controller: TransformStreamDefaultController<unknown>
|
||||
): void {
|
||||
lineNumber++;
|
||||
|
||||
// Decode the line bytes (stream: false since this is a complete line)
|
||||
let lineText: string;
|
||||
try {
|
||||
lineText = decoder.decode(lineBytes, { stream: false });
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = lineText.trim();
|
||||
if (!trimmed) {
|
||||
return; // Skip empty lines
|
||||
}
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
controller.enqueue(obj);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new TransformStream<Uint8Array, unknown>({
|
||||
transform(chunk, controller) {
|
||||
// Append chunk to buffer
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.byteLength;
|
||||
|
||||
// Process all complete lines in the buffer
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = findNewlineIndex()) !== -1) {
|
||||
// Check size limit BEFORE extracting/decoding (bytes up to newline)
|
||||
if (newlineIndex > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${newlineIndex})`
|
||||
);
|
||||
}
|
||||
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
parseLine(lineBytes, controller);
|
||||
}
|
||||
|
||||
// Check if the remaining buffer (incomplete line) exceeds the limit
|
||||
// This prevents OOM from a single huge line without newlines
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (buffered: ${totalBytes}, no newline found)`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
// Flush any remaining bytes from the decoder's internal state
|
||||
// This handles multibyte characters that may have been split across chunks
|
||||
decoder.decode(new Uint8Array(0), { stream: false });
|
||||
|
||||
// Process any remaining buffered data (no trailing newline case)
|
||||
if (totalBytes === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size limit before processing final line
|
||||
if (totalBytes > maxItemBytes) {
|
||||
throw new Error(
|
||||
`Item at line ${
|
||||
lineNumber + 1
|
||||
} exceeds maximum size of ${maxItemBytes} bytes (actual: ${totalBytes})`
|
||||
);
|
||||
}
|
||||
|
||||
const finalBytes = concatenateChunks();
|
||||
parseLine(finalBytes, controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ReadableStream into an AsyncIterable.
|
||||
* Useful for processing streams with for-await-of loops.
|
||||
*/
|
||||
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,6 @@ import { IdempotencyKeyConcern } from "../concerns/idempotencyKeys.server";
|
||||
import type {
|
||||
PayloadProcessor,
|
||||
QueueManager,
|
||||
RunNumberIncrementer,
|
||||
TraceEventConcern,
|
||||
TriggerRacepoints,
|
||||
TriggerRacepointSystem,
|
||||
@@ -54,7 +53,6 @@ export class RunEngineTriggerTaskService {
|
||||
private readonly validator: TriggerTaskValidator;
|
||||
private readonly payloadProcessor: PayloadProcessor;
|
||||
private readonly idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
private readonly runNumberIncrementer: RunNumberIncrementer;
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
private readonly tracer: Tracer;
|
||||
@@ -69,7 +67,6 @@ export class RunEngineTriggerTaskService {
|
||||
validator: TriggerTaskValidator;
|
||||
payloadProcessor: PayloadProcessor;
|
||||
idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
runNumberIncrementer: RunNumberIncrementer;
|
||||
traceEventConcern: TraceEventConcern;
|
||||
tracer: Tracer;
|
||||
metadataMaximumSize: number;
|
||||
@@ -81,7 +78,6 @@ export class RunEngineTriggerTaskService {
|
||||
this.validator = opts.validator;
|
||||
this.payloadProcessor = opts.payloadProcessor;
|
||||
this.idempotencyKeyConcern = opts.idempotencyKeyConcern;
|
||||
this.runNumberIncrementer = opts.runNumberIncrementer;
|
||||
this.tracer = opts.tracer;
|
||||
this.traceEventConcern = opts.traceEventConcern;
|
||||
this.metadataMaximumSize = opts.metadataMaximumSize;
|
||||
@@ -164,10 +160,34 @@ export class RunEngineTriggerTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(body.options?.delay));
|
||||
// Parse delay from either explicit delay option or debounce.delay
|
||||
const delaySource = body.options?.delay ?? body.options?.debounce?.delay;
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(delaySource));
|
||||
|
||||
if (parseDelayError) {
|
||||
throw new ServiceValidationError(`Invalid delay ${body.options?.delay}`);
|
||||
throw new ServiceValidationError(`Invalid delay ${delaySource}`);
|
||||
}
|
||||
|
||||
// Validate debounce options
|
||||
if (body.options?.debounce) {
|
||||
if (!delayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Debounce requires a valid delay duration. Provided: ${body.options.debounce.delay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Always validate debounce.delay separately since it's used for rescheduling
|
||||
// This catches the case where options.delay is valid but debounce.delay is invalid
|
||||
const [debounceDelayError, debounceDelayUntil] = await tryCatch(
|
||||
parseDelay(body.options.debounce.delay)
|
||||
);
|
||||
|
||||
if (debounceDelayError || !debounceDelayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ttl =
|
||||
@@ -271,97 +291,129 @@ export class RunEngineTriggerTaskService {
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
async (event, store) => {
|
||||
const result = await this.runNumberIncrementer.incrementRunNumber(
|
||||
triggerRequest,
|
||||
async (num) => {
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
number: num,
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
return { run: taskRun, error, isCached: false };
|
||||
}
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
environment: environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
||||
taskIdentifier: taskId,
|
||||
payload: payloadPacket.data ?? "",
|
||||
payloadType: payloadPacket.dataType,
|
||||
context: body.context,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
replayedFromTaskRunFriendlyId: options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: lockedToBackgroundWorker?.id,
|
||||
taskVersion: lockedToBackgroundWorker?.version,
|
||||
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
||||
concurrencyKey: body.options?.concurrencyKey,
|
||||
queue: queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
taskEventStore: store,
|
||||
ttl,
|
||||
tags,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
||||
batch: options?.batchId
|
||||
? {
|
||||
id: options.batchId,
|
||||
index: options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
depth,
|
||||
metadata: metadataPacket?.data,
|
||||
metadataType: metadataPacket?.dataType,
|
||||
seedMetadata: metadataPacket?.data,
|
||||
seedMetadataType: metadataPacket?.dataType,
|
||||
maxDurationInSeconds: body.options?.maxDuration
|
||||
? clampMaxDuration(body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: body.options?.machine,
|
||||
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
options.queueTimestamp ??
|
||||
(parentRun && body.options?.resumeParentOnCompletion
|
||||
? parentRun.queueTimestamp ?? undefined
|
||||
: undefined),
|
||||
scheduleId: options.scheduleId,
|
||||
scheduleInstanceId: options.scheduleInstanceId,
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
debounce: body.options?.debounce,
|
||||
// When debouncing with triggerAndWait, create a span for the debounced trigger
|
||||
onDebounced:
|
||||
body.options?.debounce && body.options?.resumeParentOnCompletion
|
||||
? async ({ existingRun, waitpoint, debounceKey }) => {
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
|
||||
: spanEvent.spanId;
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
// If the returned run has a different friendlyId, it was debounced.
|
||||
// For triggerAndWait: stop the outer span since a replacement debounced span was created via onDebounced.
|
||||
// For regular trigger: let the span complete normally - no replacement span needed since the
|
||||
// original run already has its span from when it was first created.
|
||||
if (
|
||||
taskRun.friendlyId !== runFriendlyId &&
|
||||
body.options?.debounce &&
|
||||
body.options?.resumeParentOnCompletion
|
||||
) {
|
||||
event.stop();
|
||||
}
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
const result = { run: taskRun, error, isCached: false };
|
||||
|
||||
if (result?.error) {
|
||||
throw new ServiceValidationError(
|
||||
taskRunErrorToString(taskRunErrorEnhancer(result.error))
|
||||
@@ -374,7 +426,13 @@ export class RunEngineTriggerTaskService {
|
||||
} catch (error) {
|
||||
if (error instanceof RunDuplicateIdempotencyKeyError) {
|
||||
//retry calling this function, because this time it will return the idempotent run
|
||||
return await this.call({ taskId, environment, body, options, attempt: attempt + 1 });
|
||||
return await this.call({
|
||||
taskId,
|
||||
environment,
|
||||
body,
|
||||
options: { ...options, runFriendlyId },
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof RunOneTimeUseTokenError) {
|
||||
|
||||
@@ -76,13 +76,6 @@ export interface PayloadProcessor {
|
||||
process(request: TriggerTaskRequest): Promise<IOPacket>;
|
||||
}
|
||||
|
||||
export interface RunNumberIncrementer {
|
||||
incrementRunNumber<T>(
|
||||
request: TriggerTaskRequest,
|
||||
callback: (num: number) => Promise<T>
|
||||
): Promise<T | undefined>;
|
||||
}
|
||||
|
||||
export interface TagValidationParams {
|
||||
tags?: string[] | string;
|
||||
}
|
||||
@@ -138,6 +131,12 @@ export type TracedEventSpan = {
|
||||
};
|
||||
setAttribute: (key: string, value: string) => void;
|
||||
failWithError: (error: TaskRunError) => void;
|
||||
/**
|
||||
* Stop the span without writing any event.
|
||||
* Used when a debounced run is returned - the span for the debounced
|
||||
* trigger is created separately via traceDebouncedRun.
|
||||
*/
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface TraceEventConcern {
|
||||
@@ -157,6 +156,17 @@ export interface TraceEventConcern {
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
traceDebouncedRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export type TriggerRacepoints = "idempotencyKey";
|
||||
|
||||
@@ -61,7 +61,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
"/api/v1/auth/jwt/claims",
|
||||
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
|
||||
/^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
|
||||
/^\/api\/v1\/deployments/, // /api/v1/deployments/*
|
||||
/^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/*
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
executeTSQL,
|
||||
type ExecuteTSQLOptions,
|
||||
type FieldMappings,
|
||||
type TSQLQueryResult,
|
||||
} from "@internal/clickhouse";
|
||||
import type { CustomerQuerySource } from "@trigger.dev/database";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { type z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { clickhouseClient } from "./clickhouseInstance.server";
|
||||
|
||||
export type { TableSchema, TSQLQueryResult };
|
||||
|
||||
export type QueryScope = "organization" | "project" | "environment";
|
||||
|
||||
const scopeToEnum = {
|
||||
organization: "ORGANIZATION",
|
||||
project: "PROJECT",
|
||||
environment: "ENVIRONMENT",
|
||||
} as const;
|
||||
|
||||
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
|
||||
ExecuteTSQLOptions<TOut>,
|
||||
"tableSchema" | "organizationId" | "projectId" | "environmentId" | "fieldMappings"
|
||||
> & {
|
||||
tableSchema: TableSchema[];
|
||||
/** The scope of the query - determines tenant isolation */
|
||||
scope: QueryScope;
|
||||
/** Organization ID (required) */
|
||||
organizationId: string;
|
||||
/** Project ID (required for project/environment scope) */
|
||||
projectId: string;
|
||||
/** Environment ID (required for environment scope) */
|
||||
environmentId: string;
|
||||
/** History options for saving query to billing/audit */
|
||||
history?: {
|
||||
/** Where the query originated from */
|
||||
source: CustomerQuerySource;
|
||||
/** User ID (optional, null for API calls) */
|
||||
userId?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a TSQL query against ClickHouse with tenant isolation
|
||||
* Handles building tenant options, field mappings, and optionally saves to history
|
||||
*/
|
||||
export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
options: ExecuteQueryOptions<TOut>
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
const { scope, organizationId, projectId, environmentId, history, ...baseOptions } = options;
|
||||
|
||||
// Build tenant IDs based on scope
|
||||
const tenantOptions: {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
} = {
|
||||
organizationId,
|
||||
};
|
||||
|
||||
if (scope === "project" || scope === "environment") {
|
||||
tenantOptions.projectId = projectId;
|
||||
}
|
||||
|
||||
if (scope === "environment") {
|
||||
tenantOptions.environmentId = environmentId;
|
||||
}
|
||||
|
||||
// Build field mappings for project_ref → project_id and environment_id → slug translation
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { organizationId },
|
||||
select: { id: true, externalRef: true },
|
||||
});
|
||||
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
where: { project: { organizationId } },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
const fieldMappings: FieldMappings = {
|
||||
project: Object.fromEntries(projects.map((p) => [p.id, p.externalRef])),
|
||||
environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])),
|
||||
};
|
||||
|
||||
const result = await executeTSQL(clickhouseClient.reader, {
|
||||
...baseOptions,
|
||||
...tenantOptions,
|
||||
fieldMappings,
|
||||
});
|
||||
|
||||
// If query succeeded and history options provided, save to history
|
||||
if (result[0] === null && history) {
|
||||
const stats = result[1].stats;
|
||||
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
|
||||
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
|
||||
|
||||
await prisma.customerQuery.create({
|
||||
data: {
|
||||
query: options.query,
|
||||
scope: scopeToEnum[scope],
|
||||
stats: { ...stats },
|
||||
costInCents,
|
||||
source: history.source,
|
||||
organizationId,
|
||||
projectId: scope === "project" || scope === "environment" ? projectId : null,
|
||||
environmentId: scope === "environment" ? environmentId : null,
|
||||
userId: history.userId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -17,6 +17,6 @@ function createRequestIdempotencyInstance() {
|
||||
},
|
||||
logLevel: env.REQUEST_IDEMPOTENCY_LOG_LEVEL,
|
||||
ttlInMs: env.REQUEST_IDEMPOTENCY_TTL_IN_MS,
|
||||
types: ["batch-trigger", "trigger"],
|
||||
types: ["batch-trigger", "trigger", "create-batch"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ClickHouse } from "@internal/clickhouse";
|
||||
import invariant from "tiny-invariant";
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { provider } from "~/v3/tracer.server";
|
||||
import { meter, provider } from "~/v3/tracer.server";
|
||||
import { RunsReplicationService } from "./runsReplicationService.server";
|
||||
import { signalsEmitter } from "./signals.server";
|
||||
|
||||
@@ -62,6 +62,7 @@ function initializeRunsReplicationInstance() {
|
||||
logLevel: env.RUN_REPLICATION_LOG_LEVEL,
|
||||
waitForAsyncInsert: env.RUN_REPLICATION_WAIT_FOR_ASYNC_INSERT === "1",
|
||||
tracer: provider.getTracer("runs-replication-service"),
|
||||
meter,
|
||||
insertMaxRetries: env.RUN_REPLICATION_INSERT_MAX_RETRIES,
|
||||
insertBaseDelayMs: env.RUN_REPLICATION_INSERT_BASE_DELAY_MS,
|
||||
insertMaxDelayMs: env.RUN_REPLICATION_INSERT_MAX_DELAY_MS,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user