Compare commits

...

134 Commits

Author SHA1 Message Date
github-actions[bot] e36b75785e chore: release v4.4.2 (#3127)
🚀 Publish Trigger.dev Docker / units (push) Failing after 4s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 20s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
# trigger.dev v4.4.2

## Summary
2 new features, 2 improvements, 8 bug fixes.

## Improvements
- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

## Bug fixes
- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

## Server changes

These changes affect the self-hosted Docker image and Trigger.dev Cloud:

- Two-level tenant dispatch architecture for batch queue processing.
Replaces the
single master queue with a two-level index: a dispatch index (tenant →
shard)
and per-tenant queue indexes (tenant → queues). This enables O(1) tenant
selection and fair scheduling across tenants regardless of queue count.
Improves batch queue processing performance.
([#3133](https://github.com/triggerdotdev/trigger.dev/pull/3133))
- Add input streams with API routes for sending data to running tasks,
SSE reading, and waitpoint creation. Includes Redis cache for fast
`.send()` to `.wait()` bridging, dashboard span support for input stream
operations, and s2-lite support with configurable S2 endpoint, access
token skipping, and S2-Basin headers for self-hosted deployments. Adds
s2-lite to Docker Compose for local development.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))
- Speed up batch queue processing by disabling cooloff and increasing
the batch queue processing concurrency limits on the cloud:
  
  - Pro plan: increase to 50 from 10.
  - Hobby plan: increase to 10 from 5.
- Free plan: increase to 5 from 1.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
- Move batch queue global rate limiter from FairQueue claim phase to
BatchQueue worker queue consumer for accurate per-item rate limiting.
Add worker queue depth cap to prevent unbounded growth that could cause
visibility timeouts.
([#3166](https://github.com/triggerdotdev/trigger.dev/pull/3166))
- Fix a race condition in the waitpoint system where a run could be
blocked by a completed waitpoint but never be resumed because of a
PostgreSQL MVCC issue. This was most likely to occur when creating a
waitpoint via `wait.forToken()` at the same moment as completing the
token with `wait.completeToken()`. Other types of waitpoints (timed,
child runs) were not affected.
([#3075](https://github.com/triggerdotdev/trigger.dev/pull/3075))
- Fix metrics dashboard chart series colors going out of sync and
widgets not reloading stale data when scrolled back into view
([#3126](https://github.com/triggerdotdev/trigger.dev/pull/3126))
- Gracefully handle oversized batch items instead of aborting the
stream.
  
When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.
  
Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))
- Require the user is an admin during an impersonation session.
Previously only the impersonation cookie was checked; now the real
user's admin flag is verified on every request. If admin has been
revoked, the session falls back to the real user's ID.
([#3078](https://github.com/triggerdotdev/trigger.dev/pull/3078))

<details>
<summary>Raw changeset output</summary>

# Releases
## @trigger.dev/build@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## trigger.dev@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/build@4.4.2`
    -   `@trigger.dev/core@4.4.2`
    -   `@trigger.dev/schema-to-json@4.4.2`

## @trigger.dev/python@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.4.2`
    -   `@trigger.dev/build@4.4.2`
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/react-hooks@4.4.2

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/redis-worker@4.4.2

### Patch Changes

- Fix slow batch queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.
([#3079](https://github.com/triggerdotdev/trigger.dev/pull/3079))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/rsc@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/schema-to-json@4.4.2

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/sdk@4.4.2

### Patch Changes

- Add input streams for bidirectional communication with running tasks.
Define typed input streams with `streams.input<T>({ id })`, then consume
inside tasks via `.wait()` (suspends the process), `.once()` (waits for
next message), or `.on()` (subscribes to a continuous stream). Send data
from backends with `.send(runId, data)` or from frontends with the new
`useInputStreamSend` React hook.
([#3146](https://github.com/triggerdotdev/trigger.dev/pull/3146))

Upgrade S2 SDK from 0.17 to 0.22 with support for custom endpoints
(s2-lite) via the new `endpoints` configuration, `AppendRecord.string()`
API, and `maxInflightBytes` session option.

- fix(sdk): batch triggerAndWait variants now return correct
run.taskIdentifier instead of unknown
([#3080](https://github.com/triggerdotdev/trigger.dev/pull/3080))

- Add PAYLOAD_TOO_LARGE error to handle graceful recovery of sending
batch trigger items with payloads that exceed the maximum payload size
([#3137](https://github.com/triggerdotdev/trigger.dev/pull/3137))

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.2`

## @trigger.dev/core@4.4.2

</details>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 09:47:24 +00:00
Eric Allam dee6f1d09e fix(batch): move batch queue global rate limiter to worker consumer level (#3166)
The global rate limiter was being applied at the FairQueue claim phase,
consuming 1 token per queue-claim-attempt rather than per item
processed.
  With many small queues (each batch is its own queue), consumers burned
  through tokens on empty or single-item queues, causing aggressive
  throttling well below the intended items/sec limit.

  Changes:
- Move rate limiter from FairQueue claim phase to BatchQueue worker
queue
    consumer loop (before blockingPop), so each token = 1 item processed
  - Replace the FairQueue rate limiter with a worker queue depth cap to
    prevent unbounded growth that could cause visibility timeouts
- Add BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH env var (optional, disabled by
default)
2026-03-03 14:44:27 +00:00
James Ritchie b192b71b93 Feat(webapp): add create custom dashboard button to metrics page (#3095)
Adds a "Create custom dashboard" button to the top right of the metrics
dashboard

 
<img width="3546" height="1934" alt="CleanShot 2026-02-19 at 11 25
12@2x"
src="https://github.com/user-attachments/assets/0bb46ade-47c9-4396-b62a-f4801d7d90b4"
/>
2026-03-02 18:23:02 +00:00
Eric Allam a9163dfd9d chore(docker): Pin goose in Dockerfile to v3.26.0 (#3163)
The latest goose requires go version 1.25:
https://github.com/pressly/goose/releases/tag/v3.27.0
2026-03-02 17:09:46 +00:00
Eric Allam 540e1c86a4 feat: Input Streams - Bidirectional task communication (#3146)
Input streams enable sending typed data to executing tasks from external
callers — backends, frontends, or other tasks. This unlocks interactive
use cases like approval UIs, cancel buttons, chat interfaces, and
human-in-the-loop AI workflows where the task needs to receive data
while running.

Three consumption patterns inside a task:

* `.wait()` — Suspend the task until data arrives (process freed, most
efficient)
* `.once()` — Wait for the next message (process stays alive)
* `.on()` — Subscribe to a continuous stream of messages

One send pattern from outside:

* `.send(runId, data)` — Send typed data to a specific run's input
stream

## User-facing API

### Define a typed input stream

```ts
import { streams, task } from "@trigger.dev/sdk";

const approval = streams.input<{ approved: boolean; reviewer: string }>({ id: "approval" });
```

### Consume inside a task

```ts
export const myTask = task({
  id: "my-task",
  run: async () => {
    // Pattern 1: Suspend until data arrives (most efficient — frees the process)
    const result = await approval.wait({ timeout: "5m" });

    // Pattern 2: Wait for next message (process stays alive)
    const data = await approval.once().unwrap();

    // Pattern 3: Subscribe to multiple messages
    approval.on((data) => { /* handle each message */ });
  },
});
```

### Send from outside

```ts
// From a backend (using secret API key)
await approval.send(runId, { approved: true, reviewer: "alice" });

// From a frontend (using public JWT token from trigger response)
const { send } = useInputStreamSend("approval", runId, { accessToken });
send({ approved: true, reviewer: "alice" });
```

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-02 16:49:54 +00:00
Eric Allam 2af5c860de chore(repo): Dispatch new-release to www.trigger.dev repo to generate version changelog draft PR (#3162) 2026-03-02 16:47:34 +00:00
James Ritchie a09038b066 Feature(webapp): new User and Project onboarding questions (#3109)
- New User onboarding questions added and stored in a new
`onboardingData` col
- Keeps the same Org creation screen and stores the data in the same
format in same DB column
- New Org onboarding questions addded and stored in a new
`onboardingData` col


https://github.com/user-attachments/assets/244e4bae-f74d-4ed4-a545-92c9b927e98b

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-03-02 14:45:14 +00:00
Eric Allam 2135dc56d6 chore(claude): Improve claude code instructions (#3161)
Also includes a claude.md audit workflow for PRs
2026-03-02 12:42:05 +00:00
Saadi Myftija 4a0fb2fc06 ci: pin vouch action version (#3160)
Pins vouch actions to `c6d80ead49839655b61b422700b7a3bc9d0804a9`
(v1.4.2) in favor of security practices. We were previously using the
`@main` tag.

Also removes the checkout steps as they're not needed in these
workflows.
2026-03-02 10:35:42 +01:00
James Ritchie e1f8134f44 fix(webapp): Removes the collapsible option from the query inspector to avoid firefox bug (#3155)
There's a bug in react-window-splitter on Firefox. When trying to expand
the inspector panel in the query editor, it checks if the main panel has
space but gets an object instead of a number for the auto-sized
query-main panel. This causes the expand calculation to fail and it
snaps it back to collapsed.

I've removed this behavior for now as it's not an important feature.
2026-02-28 20:09:13 +00:00
Oskar Otwinowski 10d6f01843 feat(vercel): Vercel SDK fixes and correct env vars behavior for staging envs (#3149) 2026-02-28 07:41:19 +00:00
Iss 24b92d3b68 docs: added runtime error note for supabase edge function (#3140) 2026-02-27 10:59:04 -05:00
Eric Allam 8003923598 feat(server): Gracefully handle oversized batch items instead of aborting the stream (#3137)
Gracefully handle oversized batch items instead of aborting the stream.

When an NDJSON batch item exceeds the maximum size, the parser now emits
an error marker instead of throwing, allowing the batch to seal
normally. The oversized item becomes a pre-failed run with
`PAYLOAD_TOO_LARGE` error code, while other items in the batch process
successfully. This prevents `batchTriggerAndWait` from seeing connection
errors and retrying with exponential backoff.

Also fixes the NDJSON parser not consuming the remainder of an oversized
line split across multiple chunks, which caused "Invalid JSON" errors on
subsequent lines.
2026-02-27 10:11:42 +00:00
Eric Allam cff45664fc fix: legacy master queue drain should never re-add entries (#3142) 2026-02-27 10:11:31 +00:00
Iss 51b6c3a580 docs: added note about Prisma 7.x for TASK_RUN_STALLED_EXECUTING error (#3138) 2026-02-26 16:01:31 -05:00
James Ritchie d5a27f08ed Fix(webapp): change "metrics" to "dashboard" (#3136)
<img width="249" height="245" alt="CleanShot 2026-02-26 at 16 44 46"
src="https://github.com/user-attachments/assets/2e38b60c-0fe4-4b88-b9b9-71df82943ace"
/>
2026-02-26 16:56:07 +00:00
Matt Aitken 719a44da01 Better explanation of batch processing concurrency (#3135) 2026-02-26 14:27:21 +00:00
Iss 92dfeb37b3 docs: Add workaround for Homebrew Bun ENOENT error to Bun guide (#3125) 2026-02-26 08:25:17 -05:00
Eric Allam b1e78a6590 feat(batch-queue): two-level tenant dispatch for fair queue (#3133)
Replace flat master queue index with two-level tenant dispatch to fix
noisy neighbor problem. When a tenant has many queues at capacity, the
scheduler now iterates tenants (Level 1) not queues, then fetches
per-tenant queues (Level 2) only for eligible tenants.

Single-deploy migration: new enqueues write to dispatch indexes only,
consumer drains old master queue alongside new dispatch path until
empty.
2026-02-26 13:07:27 +00:00
Eric Allam 5612383684 chore(repo): Improve formatting of server entries in release notes (#3134) 2026-02-26 11:59:47 +00:00
Iss 4451fcb84c docs: Query page output dot notation and metadata availability (#3132)
Clarifies in the Query docs that run metadata is not available on the
Query page and that the output column is JSON, so dot notation (e.g.
output.externalId) should be used for selecting and filtering. Adds an
example that filters by an output field in WHERE
2026-02-25 17:22:08 -05:00
Iss 863dbe8d60 docs: document waitpoint token API endpoints (#3130)
Adds REST API documentation for the 5 waitpoint token endpoints
(`/api/v1/waitpoints/tokens`), including create, list, retrieve,
complete, and HTTP callback. Also adds the `publicAccessToken` security
scheme used by the complete endpoint.

<!-- mintlify-editor-comments:start -->
Mintlify
---
0 threads from 0 users in Mintlify

- No unresolved comments
<!-- mintlify-editor-comments:end -->

<!-- mintlify-comment-->

<a
href="https://dashboard.mintlify.com/trigger/trigger/editor/docs%2Fdocument-waitpoint-endpoints?source=pr_comment"
target="_blank" rel="noopener noreferrer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"><img
src="https://d3gk2c5xim1je2.cloudfront.net/assets/open-mintlify-editor-light.svg"
alt="Open in Mintlify Editor"></picture></a>

<!-- /mintlify-comment -->
2026-02-25 12:52:14 -05:00
Eric Allam 39dd91b098 fix(engine) prevent MVCC race in blockRunWithWaitpoint pending check (#3075)
Split the CTE in blockRunWithWaitpoint so the pending waitpoint check
is a separate SQL statement. In READ COMMITTED isolation, each statement
gets its own snapshot, so a separate SELECT sees the latest committed
state from concurrent completeWaitpoint calls.

Previously, the CTE did INSERT + pending check in one statement (one
snapshot). If completeWaitpoint committed between the CTE start and
the SELECT, the SELECT would still see PENDING due to the stale
snapshot. Neither side would enqueue continueRunIfUnblocked, leaving
the run stuck forever.
2026-02-25 17:41:50 +00:00
Matt Aitken cf6b6e7063 Fix realtime connections pricing tier from 100 to 1000 (#3131)
Closes #

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

Verified the pricing definition displays the correct tier amount on the
plan selection page.

---

## Changelog

Fixed incorrect pricing tier for additional realtime connections from
$10/month per 100 to $10/month per 1000.

---

## Screenshots

N/A

💯

https://claude.ai/code/session_015QrZZJHPWta3QCBnhX2Pff

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-25 17:34:02 +00:00
Eric Allam bed3789c31 fix(batch-queue): speed up batch queue processing by disabling cooloff and fixing retry race (#3079)
Fix slow fair queue processing by removing spurious cooloff on
concurrency blocks and fixing a race condition where retry attempt
counts were not atomically updated during message re-queue.

Removed cooloff entirely from the batch queue
2026-02-25 17:33:01 +00:00
Eric Allam a482153365 feat(webapp): require the user is an admin during an impersonation session (#3078) 2026-02-25 16:07:10 +00:00
Eric Allam fe193418d0 chore(repo) auto-link server change entries to their PRs via GitHub API (#3129) 2026-02-25 16:02:32 +00:00
Matt Aitken e9fb8e3b52 Query fixes: stale widget fix, multiple series colors mismatch (#3126)
- Fix for series color assignment being out of sync with the graph
(ensures added series colors match their graph representation)
- Reload widgets when returning to screen if props changed (prevents
stale widgets after filtering and scrolling)
2026-02-25 15:50:17 +00:00
Eric Allam c05b30adfe chore(repo): fix enhanced release pr description to filter out dependency only updates (#3128) 2026-02-25 15:50:06 +00:00
Eric Allam f37bdaac84 fix(sdk): batch triggerAndWait variants now return correct run.taskIdentifier instead of unknown (#3080)
Fixes #2942
2026-02-25 15:37:04 +00:00
Eric Allam 3c0644a3b8 feat: unified GitHub release, server change tracking, and enhanced release PR (#3085)
- Add .server-changes/ convention for tracking server-only changes
- Create scripts/enhance-release-pr.mjs to deduplicate and categorize
changeset PR body
- Create scripts/generate-github-release.mjs to format unified GitHub
release body
- Change release.yml to create one unified GitHub release instead of
per-package releases
- Add update-release job to patch Docker image link after images are
pushed to GHCR
- Update changesets-pr.yml to trigger on .server-changes, enhance PR
body, and clean up consumed files
- Document server changes in CLAUDE.md, CONTRIBUTING.md, CHANGESETS.md,
and RELEASE.md
2026-02-25 13:54:19 +00:00
Eric Allam 19733c8338 docs(queues): Cover new queue limits and TTL system (#3030)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3030"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-25 11:18:44 +00:00
Matt Aitken 9ba608d2cf TRQL function tests and fixes (#3076)
What changed
- Fixed some functions like dateAdd, toString, ifNotFinite
- Removed all functions that accept lambdas as they're not supported
(yet)
- Added tests for all TRQL functions that use ClickHouse
2026-02-24 19:44:07 +00:00
Iss 89c73ed8ba docs: document run result and batch API endpoints (#3121)
Adds OpenAPI specs and sidebar pages for four previously undocumented
public endpoints: retrieve
run result, per-task batch trigger, retrieve batch, and retrieve batch
results.
2026-02-24 13:30:16 -05:00
Iss 97bf89873e docs: document undocumented run API endpoints (#3120)
Adds API reference pages for three previously undocumented run
endpoints: retrieve run events, retrieve run trace, and add tags to a
run.
2026-02-24 10:49:39 -05:00
Iss b60788df82 docs: note that onCancel only runs during active execution (#3119)
Adds a warning to the onCancel docs clarifying that the hook only fires
when a run is actively executing
2026-02-23 21:24:38 +00:00
Eric Allam 6409fea6ac fix(engine): allow disabling the ttl system consumers independently from the whole system (#3115) 2026-02-23 16:39:30 +00:00
Eric Allam ae46e3f7c8 feat(server): New TTL system, enforce max queue length limits, lazy waitpoint creation (#2980)
This PR implements a new run TTL system and queue size limits to prevent
unbounded queue growth which should help prevent situations where queues
enter a "death spiral" where the queue will never be able to catch up.

The main/correct way to battle this situation is to enforce a maximum
TTL on all runs (e.g. up to 14 days) where runs that have been queued
for that maximum TTL will get auto-expired, making room for newer runs
to execute. This required creating a new TTL system that can handle
higher workloads and is now deeply integrated into the RunQueue. When
runs are enqueued with a TTL, they are added to their normal queue as
well as to the TTL queue. When runs are dequeued, they are removed from
both their normal queue and the TTL queue. If runs are dequeued by the
TTL system, they are removed from their normal queue. Both these
dequeues happen automatically so there is no race condition.

The TTL expiration system is also made reliable by expiring runs via a
Redis worker, which is enqueued to atomically inside the TTL dequeue lua
script.

### Optional associated waitpoints

Additionally, this PR implements an optimization where runs that aren't
triggered with a dependent parent run will no longer create an
associated waitpoint. Associated waitpoints are then lazily created if a
dependent run wants to wait for the child run post-facto (via debounce
or idempotency), which is a rare situation but is possible. This means
fewer waitpoint creations but also fewer waitpoint completions for runs
with no dependencies.

### Environment Queue Limits

Prevents any single queue growing too large by enforcing queue size
limits at trigger time.

- Queue size checks happen at trigger time - runs are rejected if queue
would exceed limit
- Dashboard UI shows queue limits on both the Queues page and a new
Limits page
- In-memory caching for queue size checks to reduce Redis load

### Batch trigger fixes

Currently when a batch item cannot be created for whatever reason (e.g.
queue limits) the run will never get created, which means a stalled run
if using `batchTriggerAndWait`. We've updated the system to handle this
differently: now when a batch item cannot be triggered and converted
into a run, we will eventually (after retrying 8 times up to 30s) we
will create a "pre-failed" run with the error details, correctly
resolving the batchTriggerAndWait.
2026-02-23 15:57:32 +00:00
Oskar Otwinowski 69dc7bcde8 feat(webapp): Vercel / Slack integrations improvements (#3108)
##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Slack + GitHub + Vercel + Builds + Deployments

---

## Changelog

Settings changes:
- Split general from integrations
- Add new Slack section to org level integrations
Vercel improvements:
- bugfix for TRIGGER_SECRET_KEY collision
- onboarding improvements for connecting to projects
- new loops event
Slack improvements:
- nicer alerts
Webhook/Email alerts:
- rich events with Github & Vercel integration data

---

## Screenshots


<img width="2550" height="652" alt="Screenshot 2026-02-20 at 21 53 34"
src="https://github.com/user-attachments/assets/8d7c9f1d-5fe9-4516-8fb3-885460b4207f"
/>
<img width="843" height="710" alt="Screenshot 2026-02-23 at 10 55 54"
src="https://github.com/user-attachments/assets/8ea72c1f-431b-493c-b9a9-8076cce12262"
/>
<img width="765" height="466" alt="Screenshot 2026-02-20 at 21 52 46"
src="https://github.com/user-attachments/assets/157fafb8-b7bf-499d-8953-c2aed5e44ce0"
/>
<img width="691" height="261" alt="Screenshot 2026-02-20 at 22 04 24"
src="https://github.com/user-attachments/assets/3aea7369-2008-4af8-a9c0-5fbfa2cc381d"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 48 49"
src="https://github.com/user-attachments/assets/dc10c14e-cd15-445a-b5be-d694d29d20e5"
/>
<img width="2032" height="1114" alt="Screenshot 2026-02-19 at 14 49 04"
src="https://github.com/user-attachments/assets/1ef591fd-fd00-430a-9649-8b18cff9586d"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 32 56"
src="https://github.com/user-attachments/assets/c5c8f318-d193-4dd4-86f7-1cc4bbcc4e0c"
/>
<img width="422" height="187" alt="Screenshot 2026-02-20 at 21 57 41"
src="https://github.com/user-attachments/assets/37865cb6-4c0d-40ef-9c60-7b057d546c61"
/>
<img width="1583" height="1115" alt="Screenshot 2026-02-19 at 17 33 06"
src="https://github.com/user-attachments/assets/e9180e8e-e611-4734-9232-80c62ff863ad"
/>

💯
2026-02-23 13:48:09 +00:00
Matt Aitken 3a7054628f Fix: show the deprecation panel if it's an old project and v3 (#3113)
Without doing an expensive query we can’t tell if it’s definitely a v3
projects – like getting run counts.
So let’s just assume if the project hasn’t been upgraded to v4 (by
running dev/deploy CLI with v4) AND the project is older than the v4
release then it’s v3.
2026-02-23 11:10:37 +00:00
Eric Allam 676525279a docs: otel metrics (#3096) 2026-02-20 16:51:48 +00:00
James Ritchie 79f8cdef72 Fix(webapp): logs button + logs table row link fix (#3107)
Small fixes and improvements to the logs page:

- Clicking the Run ID didn't open inspector
- Swapped the "open link in tab" icon with Runs icon
- Prevent tooltip hovering on Level info

<img width="350" height="206" alt="CleanShot 2026-02-20 at 10 00 37@2x"
src="https://github.com/user-attachments/assets/3e82f24a-c0a1-4c01-a8e9-9e06a8af982a"
/>
2026-02-20 16:44:55 +00:00
github-actions[bot] 98bf706437 chore: release v4.4.1 (#3100)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 6s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/build@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## trigger.dev@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/build@4.4.1`
    -   `@trigger.dev/core@4.4.1`
    -   `@trigger.dev/schema-to-json@4.4.1`

## @trigger.dev/python@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.4.1`
    -   `@trigger.dev/build@4.4.1`
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/react-hooks@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/redis-worker@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/rsc@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/schema-to-json@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/sdk@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/core@4.4.1

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-20 16:40:06 +00:00
Iss 354363e408 docs: Vercel integration – marketplace link, Build options, Root Directory warning (#3097)
Adds a direct Vercel Marketplace link, documents configuring build
options via the project config page, and adds a warning and workaround
for projects using a Vercel Root Directory
2026-02-20 10:56:44 -05:00
Iss 525145def0 docs: v3 deprecation notice and Migrate using AI updates on migrating-from-v3 (#3098)
Adds a deprecation warning at the top of the migrating-from-v3 page and
updates the “Migrate using AI” prompt and intro
2026-02-20 10:55:48 -05:00
Eric Allam 23c327ea16 fix(metrics): disable collecting system and filesystem metrics in deployed workers (#3106) 2026-02-20 15:45:35 +00:00
Eric Allam d794101c67 fix(webapp): fix broken MFA by only committing one auth session set-cookie call (#3104)
Co-authored-by: Oskar Otwinowski <oskar.otwinowski@gmail.com>
2026-02-20 15:09:53 +00:00
Eric Allam f325638892 fix(tests): fix flaky getSnapshotsSince test (#3103) 2026-02-20 14:52:59 +00:00
Eric Allam 68e3f8c9db chore(clickhouse): more clickhouse migration conflict fixes (#3102) 2026-02-20 13:28:58 +00:00
Eric Allam 2071090042 chore(clickhouse): fix clickhouse migration version conflict (#3101) 2026-02-20 14:24:56 +01:00
Eric Allam 469b039090 feat: OTEL metrics pipeline for task workers (#3061)
- Adds an end-to-end OTEL metrics pipeline: task workers collect and
export metrics via OpenTelemetry, the webapp ingests them into
ClickHouse, and they're queryable through the existing dashboard query
engine
- Workers emit process CPU/memory metrics (via
`@opentelemetry/host-metrics`) and Node.js runtime metrics (event loop
utilization, event loop delay, heap usage)
- Users can create custom metrics in their tasks via
`otel.metrics.getMeter()` from `@trigger.dev/sdk`
- Metrics are automatically tagged with run context (run ID, task slug,
machine, worker version) so they can be sliced per-run, per-task, or
per-machine
- The TSQL query engine gains metrics table support with typed attribute
columns, `prettyFormat()` for human-readable values, and per-schema time
bucket thresholds
- Includes reference tasks
(`references/hello-world/src/trigger/metrics.ts`) demonstrating
CPU-intensive, memory-ramp, bursty workload, and custom metrics patterns

## What changed

### Metrics collection (packages/core, packages/cli-v3)
- **Metrics export pipeline** — `TracingSDK` now sets up a
`MeterProvider` with a `PeriodicExportingMetricReader` that chains
through `TaskContextMetricExporter` (adds run context attributes) and
`BufferingMetricExporter` (batches exports to reduce overhead)
- **Host metrics** — Enabled `@opentelemetry/host-metrics` for process
CPU, memory, and system-level metrics
- **Node.js runtime metrics** — New `nodejsRuntimeMetrics.ts` module
using `performance.eventLoopUtilization()`, `monitorEventLoopDelay()`,
and `process.memoryUsage()` to emit 6 observable gauges
- File system and diskio metrics
- **Custom metrics** — Exposed `otel.metrics` from `@trigger.dev/sdk` so
users can create counters, histograms, and gauges in their tasks
- **Machine ID** — Stable per-worker machine identifier for grouping
metrics
- **Dev worker** — Drops `system.*` metrics to reduce noise, keeps
sending metrics between runs in warm workers

### Metrics ingestion (apps/webapp)
- **OTEL endpoint** — `otel.v1.metrics.ts` accepts OTEL metric export
requests (JSON and protobuf), converts to ClickHouse rows
- **ClickHouse schema** — `017_create_metrics_v1.sql` with 10-second
aggregation buckets, JSON attributes column, 60-day TTLs

### Query engine (internal-packages/tsql, apps/webapp)
- **Metrics query schema** — Typed columns for metric attributes
(`task_identifier`, `run_id`, `machine_name`, `worker_version`, etc.)
extracted from the JSON attributes column
- **`prettyFormat()`** — TSQL function that annotates columns with
format hints (`bytes`, `percent`, `durationSeconds`) for frontend
rendering without changing the underlying data
- **Per-schema time buckets** — Different tables can define their own
time bucket thresholds (metrics uses tighter intervals than runs)
- **AI query integration** — The AI query service knows about the
metrics table and can generate metric queries
- **Chart improvements** — Better formatting for byte values,
percentages, and durations in charts and tables

### Reference project
- **`references/hello-world/src/trigger/metrics.ts`** — 6 example tasks:
`cpu-intensive`, `memory-ramp`, `bursty-workload`, `sustained-workload`,
`concurrent-load`, `custom-metrics`

## Test plan

- [ ] Build all packages and webapp
- [ ] Start dev worker with hello-world reference project
- [ ] Run `cpu-intensive`, `memory-ramp`, and `custom-metrics` tasks
- [ ] Verify metrics in ClickHouse: `SELECT DISTINCT metric_name FROM
metrics_v1`
- [ ] Query via dashboard AI: "show me CPU utilization over time"
- [ ] Verify `prettyFormat` renders correctly in chart tooltips and
table cells
- [ ] Confirm dev worker drops `system.*` metrics but keeps `process.*`
and `nodejs.*`
2026-02-20 13:16:34 +00:00
Eric Allam 38981f5e70 chore(docs): cover maxDelay debounce option (#2985)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2985">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-19 13:50:33 +00:00
Matt Aitken 22505e9803 Query and Metrics docs (#3074)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 13:50:13 +00:00
github-actions[bot] 84a00b6eb6 chore: release v4.4.0 (#2941)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 3s
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/sdk@4.4.0

### Minor Changes

- Added `query.execute()` which lets you query your Trigger.dev data
using TRQL (Trigger Query Language) and returns results as typed JSON
rows or CSV. It supports configurable scope (environment, project, or
organization), time filtering via `period` or `from`/`to` ranges, and a
`format` option for JSON or CSV output.
([#3060](https://github.com/triggerdotdev/trigger.dev/pull/3060))

    ```typescript
    import { query } from "@trigger.dev/sdk";
    import type { QueryTable } from "@trigger.dev/sdk";

    // Basic untyped query
const result = await query.execute("SELECT run_id, status FROM runs
LIMIT 10");

    // Type-safe query using QueryTable to pick specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" |
"status" | "triggered_at">>(
      "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
    );
    typedResult.results.forEach((row) => {
      console.log(row.run_id, row.status); // Fully typed
    });

    // Aggregation query with inline types
const stats = await query.execute<{ status: string; count: number }>(
      "SELECT status, COUNT(*) as count FROM runs GROUP BY status",
      { scope: "project", period: "30d" }
    );

    // CSV export
    const csv = await query.execute("SELECT run_id, status FROM runs", {
      format: "csv",
      period: "7d",
    });
    console.log(csv.results); // Raw CSV string
    ```

### Patch Changes

- Add `maxDelay` option to debounce feature. This allows setting a
maximum time limit for how long a debounced run can be delayed, ensuring
execution happens within a specified window even with continuous
triggers.
([#2984](https://github.com/triggerdotdev/trigger.dev/pull/2984))

    ```typescript
    await myTask.trigger(payload, {
      debounce: {
        key: "my-key",
        delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous
triggers
      },
    });
    ```

- Aligned the SDK's `getRunIdForOptions` logic with the Core package to
handle semantic targets (`root`, `parent`) in root tasks.
([#2874](https://github.com/triggerdotdev/trigger.dev/pull/2874))

- Export `AnyOnStartAttemptHookFunction` type to allow defining
`onStartAttempt` hooks for individual tasks.
([#2966](https://github.com/triggerdotdev/trigger.dev/pull/2966))

- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/build@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## trigger.dev@4.4.0

### Patch Changes

- Fix runner getting stuck indefinitely when `execute()` is called on a
dead child process.
([#2978](https://github.com/triggerdotdev/trigger.dev/pull/2978))
- Add optional `timeoutInSeconds` parameter to the
`wait_for_run_to_complete` MCP tool. Defaults to 60 seconds. If the run
doesn't complete within the timeout, the current state of the run is
returned instead of waiting indefinitely.
([#3035](https://github.com/triggerdotdev/trigger.dev/pull/3035))
- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`
    -   `@trigger.dev/build@4.4.0`
    -   `@trigger.dev/schema-to-json@4.4.0`

## @trigger.dev/core@4.4.0

### Patch Changes

- Add `maxDelay` option to debounce feature. This allows setting a
maximum time limit for how long a debounced run can be delayed, ensuring
execution happens within a specified window even with continuous
triggers.
([#2984](https://github.com/triggerdotdev/trigger.dev/pull/2984))

    ```typescript
    await myTask.trigger(payload, {
      debounce: {
        key: "my-key",
        delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous
triggers
      },
    });
    ```

- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))

- fix: vendor superjson to fix ESM/CJS compatibility
([#2949](https://github.com/triggerdotdev/trigger.dev/pull/2949))

Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on
Node.js versions that don't support `require(ESM)` by default (&lt;
22.12.0) and AWS Lambda which intentionally disables it.

- Add Vercel integration support to API schemas: `commitSHA` and
`integrationDeployments` on deployment responses, and `source` field for
environment variable imports.
([#2994](https://github.com/triggerdotdev/trigger.dev/pull/2994))

## @trigger.dev/python@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`
    -   `@trigger.dev/sdk@4.4.0`
    -   `@trigger.dev/build@4.4.0`

## @trigger.dev/react-hooks@4.4.0

### Patch Changes

- Fix `onComplete` callback firing prematurely when the realtime stream
disconnects before the run finishes.
([#2929](https://github.com/triggerdotdev/trigger.dev/pull/2929))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/redis-worker@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/rsc@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/schema-to-json@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-19 13:38:10 +00:00
James Ritchie 506b161751 feature(webapp): Show a v3 deprecation notice in the side bar (#3090)
- If your project is v3, show a v3 deprecation panel in the side menu
- If there is an active incident, show the incident panel instead
- Links to the Migration guide in the docs
- Displays when the side menu is collapsed



https://github.com/user-attachments/assets/f8492713-c58b-4f83-bcce-0e85f4a967ef

<img width="972" height="694" alt="CleanShot 2026-02-19 at 08 13 59@2x"
src="https://github.com/user-attachments/assets/0599dd20-d598-48c6-b83c-208648cee071"
/>
2026-02-19 11:02:10 +00:00
Matt Aitken 1ec7722690 Add Queue Management API endpoints and SDK documentation (#3087)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

N/A - Documentation and OpenAPI schema updates only.

---

## Changelog

Added comprehensive Queue Management API support:

**OpenAPI Endpoints:**
- `GET /api/v1/queues` - List all queues with pagination support
- `GET /api/v1/queues/{queueParam}` - Retrieve a specific queue by ID,
task ID, or custom queue name
- `POST /api/v1/queues/{queueParam}/pause` - Pause or resume a queue
- `POST /api/v1/queues/{queueParam}/concurrency/override` - Override
queue concurrency limits
- `POST /api/v1/queues/{queueParam}/concurrency/reset` - Reset
concurrency limits to base values

**Schema Definitions:**
- `QueueObject` - Complete queue representation with concurrency details
- `ListQueuesResult` - Paginated queue listing response

**Documentation:**
- Updated `queue-concurrency.mdx` with SDK usage examples for queue
management
- Added 5 new management API documentation pages for each endpoint
- Updated `docs.json` navigation structure with new "Queues API" section

All endpoints support flexible queue identification (by ID, task ID, or
custom queue name) and include TypeScript code samples.

---

## Screenshots

N/A

💯

https://claude.ai/code/session_01LyrXwxHCbejvi34fykifPP

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-18 22:35:39 +00:00
Iss 2825a200e6 docs: added v3 to v4 migreation note about IP change (#3086) 2026-02-18 18:08:00 +00:00
Matt Aitken eb0f963393 feat(metrics): Dashboard charts performance improvements (#3083)
Summary
- Only render the top 50 series
- Improved rendering performance on bar charts
2026-02-18 16:12:00 +00:00
nicktrn 59b6eb9a3e feat(webapp): add region selector to test and replay task (#3082)
Adds a region selector to the Test task page and Replay run dialog, so
users can override the region from the dashboard. Disabled with a
placeholder for dev environments.

Closes #3016
2026-02-18 10:39:38 +00:00
Mihai Popescu b793f33e59 Logs: new materialized view and some UI improvements (#3069)
This will prevent internal logs to be added to the
task_events_search_table

Closes #<issue>

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Ran the migration, deleted the old invalid rows and ran new tasks.
The undesired logs are not added to the table.

---

## Changelog

Updated the MATERIALIZED VIEW to also filter for `trace_id != ''`

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-02-18 09:48:52 +00:00
Oskar Otwinowski 6e0b54b081 docs(vercel): add comprehensive Vercel integration docs (#3081)
Expand documentation for the Vercel integration with detailed usage,
installation, environment variable sync, atomic deployments, and
environment mapping. Replace the previous "coming soon" placeholder with
complete instructions and UI flow for connecting via the Trigger.dev
dashboard or the Vercel Marketplace. Explain required GitHub
integration,
how env vars sync in both directions, which vars are excluded, and how
to control sync behavior. Describe atomic deployments (default for
production), how they gate Vercel deployments to ensure task/app
consistency, and note related configuration changes. Add tips and notes
to guide setup and troubleshooting.

This provides users with actionable guidance to connect Vercel, map
environments, and keep app and tasks in sync without custom CI scripts.
2026-02-17 18:19:00 +01:00
Eric Allam 4c986ad1eb fix(docs): Fix loop to iterate over results.runs (#3077)
To get access to the runs from `batch.triggerAndWait` use `results.runs`
2026-02-17 13:52:58 +01:00
Saadi Myftija 7af789bc3e fix(deployments): external build token generation issue (#3070)
Fixes an issue introduced in #3024.

The behavior for local builds in older CLI versions relies on
`externalBuildData` to be defined to distinguish from the self-hosting
local build path, even though it doesn't actually use the token.
2026-02-16 19:05:40 +01:00
Oskar Otwinowski 72ce6a8300 fix(vercel): Keep search params for OAuth redirects (#3071) 2026-02-16 18:51:33 +01:00
Pramod Dhungana 921285ca32 add friendly messages for common http error codes (#3001)
Closes #<issue>

##  Checklist

- [ x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x ] The PR title follows the convention.
- [ x] I ran and tested the code works

---

## Testing

Verified the error message mapping logic locally and ensured existing
behavior remains unchanged.


---

## Changelog

Added friendly messages for HTTP 401, 403, and 429 errors.





<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3001">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-16 11:42:26 +00:00
Iss 667a93ca3c docs: add ClickHouse migration troubleshooting tip (#3011)
Adds troubleshooting tip for when ClickHouse migrations report "no
migrations to run" but the schema is missing. This happens when the
goose migration tracker is out of sync with the actual schema state.

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3011"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-16 11:41:31 +00:00
DKP 4dfa65809a Dropped bracket highlight and border by 50% (#3068) 2026-02-16 11:31:02 +00:00
Matt Aitken d4cd34094e Query API and SDK (#3060)
Summary
- Add API endpoint to run TRQL queries
- Implement SDK function for executing queries

## SDK
Added `query.execute()` which lets you query your Trigger.dev data using
TRQL (Trigger Query Language) and returns results as typed JSON rows or
CSV. It supports configurable scope (environment, project, or
organization), time filtering via `period` or `from`/`to` ranges, and a
`format` option for JSON or CSV output.

```typescript
import { query } from "@trigger.dev/sdk";
import type { QueryTable } from "@trigger.dev/sdk";

// Basic untyped query
const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");

// Type-safe query using QueryTable to pick specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
  "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
);
typedResult.results.forEach(row => {
  console.log(row.run_id, row.status); // Fully typed
});

// Aggregation query with inline types
const stats = await query.execute<{ status: string; count: number }>(
  "SELECT status, COUNT(*) as count FROM runs GROUP BY status",
  { scope: "project", period: "30d" }
);

// CSV export
const csv = await query.execute(
  "SELECT run_id, status FROM runs",
  { format: "csv", period: "7d" }
);
console.log(csv.results); // Raw CSV string
```
2026-02-16 10:53:41 +00:00
James Ritchie 796ad29386 Fix(webapp): consistent query button width (#3067)
Prevents layout shift by keeping the button width the same when
isLoading

Normal state
<img width="423" height="186" alt="CleanShot 2026-02-16 at 10 31 23"
src="https://github.com/user-attachments/assets/232bc14f-dc2c-4092-9b46-f6d652568633"
/>

isLoading
<img width="403" height="137" alt="CleanShot 2026-02-16 at 10 31 43"
src="https://github.com/user-attachments/assets/e9215b48-c2cc-4ede-91ff-08bbdb2382a0"
/>
2026-02-16 10:49:37 +00:00
James Ritchie b4e08bddec Fix(webapp): metrics UI improvements (#3063)
- Lots of small UI improvements
- Toggle full screen charts with "V" shortcut

<img width="3504" height="2286" alt="CleanShot 2026-02-14 at 20 06
30@2x"
src="https://github.com/user-attachments/assets/32403661-06b3-4f6c-a074-243b3f43197d"
/>

<img width="362" height="222" alt="CleanShot 2026-02-14 at 20 06 58@2x"
src="https://github.com/user-attachments/assets/ee4f5859-4b71-482e-9636-5abd78d9f623"
/>

<img width="434" height="360" alt="CleanShot 2026-02-14 at 20 06 53@2x"
src="https://github.com/user-attachments/assets/44339348-f3f3-425b-bbf3-bb41ed6a8e59"
/>

<img width="460" height="310" alt="CleanShot 2026-02-14 at 20 06 48@2x"
src="https://github.com/user-attachments/assets/eb3b6af6-b88a-4677-8a48-acfa6b768770"
/>

<img width="502" height="332" alt="CleanShot 2026-02-14 at 20 06 45@2x"
src="https://github.com/user-attachments/assets/b788d78b-8f4a-4b18-94c8-487673f3ec7b"
/>

<img width="370" height="257" alt="CleanShot 2026-02-14 at 20 10 29@2x"
src="https://github.com/user-attachments/assets/a81e8b14-b2eb-402c-bf3b-affd0d4fde26"
/>
2026-02-14 21:26:25 +00:00
Iss 5d6085dcff docs: Realtime skipColumns and Bun indexing troubleshooting (#3054)
Documents the skipColumns option on useRealtimeRun and
useRealtimeRunsWithTag for status-only subscriptions (smaller payloads,
e.g. for progress/completion UI). Adds a troubleshooting section for the
“Failed to index deployment” source-map error when using the Bun
runtime, with a pnpm patch workaround and link to the GitHub issue
2026-02-14 14:02:00 -05:00
nicktrn 1d744fa3c6 fix(dashboard): apply time filter preset periods immediately on click (#3053)
Extract `applyPeriod` callback from `applySelection` so preset period
buttons ("Created in the last X") apply immediately when clicked,
instead of only updating the selection state and requiring a separate
apply step.

Also validates `maxPeriodDays` on instant-apply so the upgrade prompt
still works correctly for plan-limited periods.
2026-02-13 18:35:39 +00:00
Matt Aitken a3d3b17df4 TRQL: always add FINAL keyword (#3051)
For now we’re going to always add FINAL to TRQL queries for data
correctness.

In the future we will implement an automated optimization where we use
`SELECT argMax(column, _version)` and `WHERE _is_deleted = 0`. But this
is a more complex change and needs more investigation of downsides.
2026-02-13 17:34:43 +00:00
James Ritchie 35e11e09a3 Fix(webapp): Show an error to the user when their add-on upgrade payment fails (#3050)
A customer experienced a bug where their subscription downgraded to the
free plan unintentionally. This was due to a concurrency upgrade payment
attempt that failed a card check. We auto retry the payment across 2
weeks of attempts. When the final attempt failed, the whole subscription
downgraded.

Now we check if the payment is successful and if not, return an error
immediately so the subscription isn't modified until a successful
payment is made for an upgrade.
2026-02-13 16:52:04 +00:00
Matt Aitken 9030e94362 Metrics improvements (#3046)
Summary
- Remove LIMIT from built-in dashboard queries
- Make concurrency configurable per project via environment variables
- Fix widget fallback period to Metrics default (1d) instead of 7d
- Handle concurrency at the project level
- Sort series for graphs so largest is displayed at the bottom (legend
shows largest at top)
- Use average aggregation for some built-in charts
- Improve aggregation handling for the legend
- Only render chart points when there is data; render dots on line
charts
- Truncate legend items and show tooltip on hover
- Better preserve chart configuration when the underlying query changes
2026-02-13 15:57:11 +00:00
Eric Allam 2462c80c8a chore(vouch): vouch for capaj (#3048) 2026-02-13 15:56:05 +01:00
Mihai Popescu a8f9a90280 improv(webapp): Add new table for optimized logs search (#3036)
##  Checklist

- [X] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [X] The PR title follows the convention.
- [X] I ran and tested the code works

---

## Testing

Tested the migration on test env and locally.
Tested query and merge performance.
Generated tasks and observed the ingested data and searches.

---

## Changelog

* New ClickHouse table & MV (task_events_search_v1): A search-optimized
materialized view that filters out debug events, partial spans, and
empty span events at ingestion time.
* ClickHouse client updates: New getLogsSearchListQueryBuilder and
taskEventsSearch accessor on the ClickHouse class.
* LogsListPresenter: Switches to the new search table, uses
triggered_timestamp for cursor pagination instead of unixTimestamp.
  *  Spans route: Also switches to the new search query builder.
* Seed spanSpammer task: Adds a 10s trace with events and metadata
operations for testing.
2026-02-13 16:28:14 +02:00
Oskar Otwinowski 4b7f67604a feat(webapp/deployments): Vercel improvements & fixes (#3037) 2026-02-13 14:06:22 +01:00
Matt Aitken bfe417f679 docs: add note about single-issue PRs to contributing guide (#3044)
Add a prominent notice at the top of CONTRIBUTING.md clarifying that we
only accept PRs addressing a single issue, not multiple fixes.

https://claude.ai/code/session_011ZNQd5zkf38piSMhWbgiWt

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-13 10:07:06 +00:00
Eric Allam ba320a4bdb chore(vouch): vouch for gautamsi (#3039) 2026-02-12 22:27:14 +00:00
Matt Aitken bc0d1ff59a Metrics dashboards (#3019)
Summary
- Implemented metrics dashboards with a built-in dashboard and custom
dashboards
- Added a "Big number” display type

What changed
- New data format for metric layouts and saving/editing layouts
(editing, saving, cancel revert)
  - QueryWidget usable on Query page and Metrics dashboards
  - Time filtering, auto-reloading and timeBucket() auto-bin support
- Filters added to metrics; widget popover/improved history and blank
states
- Side menu:
- Metrics/Insights section with icons, colors, padding, collapsible
behavior and reordering of custom dashboards
- Move action logic into service for reuse and API querying; refactor
reordering for reuse
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3019"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-02-12 17:48:02 +00:00
Eric Allam 062bcaece8 feat(mcp): add timeout parameter to wait_for_run_to_complete tool (#3035)
## Summary

- Adds an optional `timeoutInSeconds` parameter (default 60s) to the
`wait_for_run_to_complete` MCP tool
- If the run doesn't complete within the timeout, returns the current
run state instead of blocking indefinitely
- Uses `AbortSignal.timeout()` combined with the existing MCP signal

Fixes #3032
2026-02-12 16:14:25 +00:00
nicktrn c2085e6cc6 feat(dashboard): link git sha and ref to GitHub on settings page (#3034)
Make the git SHA and git ref in the org settings sidebar clickable links
to GitHub — SHA links to the commit, ref links to the branch/tag.
2026-02-12 15:31:52 +00:00
James Ritchie d7bc37fdc0 Feat(dashboard): show the Betterstack incident title in the dashboard (#3006)
When the incident panel is displayed, show the title added to
BetterStack as the contents of the incident panel.

I've also brightened the UI so it's more visible.

<img width="536" height="590" alt="CleanShot 2026-02-04 at 20 46 36@2x"
src="https://github.com/user-attachments/assets/040a04f8-5b52-40e8-8892-51c8efd6c08c"
/>

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3006"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-12 08:58:21 +00:00
DKP 6e3ac8bd91 docs: cursor cli docs update (remove chmod workaround) (#3031)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3031"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-11 13:32:49 +00:00
Matt Aitken 170fde3498 Move vouch requirement to top of CONTRIBUTING.md (#3029)
Contributors need to be vouched before opening PRs, but this requirement
was buried far down in the document. This change:
- Adds mention of vouches in the intro paragraph
- Moves the "Getting vouched" section to right after the intro

This makes the requirement more visible to new contributors.

Slack thread:
https://triggerdotdev.slack.com/archives/C0A7Q6F62NS/p1770805895370749
https://claude.ai/code/session_01G6VVbgfUAeCpJfedELdqq1
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3029"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 10:44:14 +00:00
Iss ddeb9c415e docs: heartbeats, Bun version, troubleshooting, and preview-branch cleanup (#3026)
Doc updates: 
- new Heartbeats page (yield, progress, external updates)
- Bun supported-version note
-  resource_exhausted troubleshooting with native builder link
- GitHub Actions preview-branch example with closed trigger so branches
archive when PRs close
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3026"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-10 21:53:41 +00:00
Saadi Myftija 2feecece88 fix(api): skip external build creation for native builds (#3024)
Native builds don't use depot, but the `/deployments/:id/progress`
endpoint was unconditionally generating depot build tokens. This is now
fixed.

The initialize deployment endpoint was already doing this check.

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3024"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-10 19:44:44 +01:00
Oskar Otwinowski 48a96efbdc chore(webapp): Expose Vercel errors (#3025) 2026-02-10 18:30:56 +01:00
DKP ebffa1039c docs: added Cursor background agent docs (#3023)
- Adds a new example project guide for running Cursor's headless CLI
agent as a Trigger.dev task with live Realtime Streams output
- New doc page at `guides/example-projects/cursor-background-agent.mdx`
- Added to sidebar nav and example projects table
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3023"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-10 16:47:00 +00:00
Eric Allam bc63edd6bf chore(repo): adopt vouch with issue based workflow and require for PRs (#3022)
Adopting [https://github.com/mitchellh/vouch](vouch) so we can help
potential contributors by requiring a conversation before they can
submit a PR. Too many contributors have been skipping the conversation
part of contributing to an OSS repo and skipping right ahead to
submitting PRs
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3022"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-10 12:03:24 +00:00
Mihai Popescu eaed7d0ba4 fix(webapp): UI/UX improvements for logs, query, and shortcuts (#2997)
##  Checklist

- [X] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [X] The PR title follows the convention.
- [X] I ran and tested the code works

---

## Testing

Manually tested each implementation.

---

## Changelog

* Updated Logs Page with the new implementation in time filter component

* In TRQL editor users can now click on empty/blank spaces in the editor
and the cursor will appear

* Added CMD + / for line commenting in TRQL

* Activated proper undo/redo functionality in CodeMirror (TRQL editor)

* Added a check for new logs button, previously once the user got to the
end of the logs he could not check for newer logs

* Added showing MS in logs page Dates

* Removed LOG_INFO internal logs, they are available with Admin Debug
flag

* Added support for correct timezone render on server side.

* Increased CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE to 1GB

* Changed Previous run/ Next run to J/K, consistent with previous/next
page in Runs list
2026-02-10 12:19:26 +02:00
Oskar Otwinowski 9b21f8d322 feat(webapp): Vercel integration (#2994)
Vercel integration

Desc + Vid coming soon


For human reviewer:
- check the db schema
- check if posthog user attribution call is correct (telemetry.server.ts
& `referralSource`)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2994"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-10 10:37:09 +01:00
nicktrn e536d35b17 fix(ci): fix docker image publishing and worker builds (#3013)
## Summary
- **Fix Docker publish automation**: The `v.docker.*` tags pushed by the
release workflow using `GITHUB_TOKEN` don't trigger the publish workflow
(GitHub Actions limitation to prevent infinite loops). Added a
`workflow_call` to `publish.yml` directly from the release job so Docker
images are built automatically after npm publish. Tags are still pushed
for reference.
- **Fix worker Containerfiles**: The coordinator, docker-provider, and
kubernetes-provider builds have been failing since the superjson
vendoring change in `@trigger.dev/core` (#2949). The Containerfiles now
run `bundle-vendor` before `build:bundle` to generate the vendor files
that esbuild needs.

### Context
- Docker images on GHCR have been stuck at v4.3.0 — v4.3.1, v4.3.2,
v4.3.3 tags existed on GitHub but never triggered publish runs
- The worker builds (publish-worker) have been failing on every push to
main since Jan 30

## Test plan
- [x] Verified kubernetes-provider Containerfile builds locally with the
fix
- [x] Manually dispatched publish workflow for v4.3.1 — all jobs
succeeded
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3013"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-06 13:56:39 +00:00
DKP b96a0b70d4 Docs: Clarify AI tool compatibility and expand context snippet (#3000)
This pull request overhauls the "Building with AI" documentation
section. It includes a comprehensive restructuring of the main
building-with-ai page with new setup guides and troubleshooting
sections, reorganizes the navigation hierarchy to elevate
mcp-agent-rules as a top-level page, and updates multiple documentation
pages to clarify the relationships between three AI tools: Skills, Agent
Rules, and MCP Server. Changes also include formatting improvements,
such as replacing italicized text with inline code formatting, and
consistent additions of explanatory Note blocks and CardGroup components
across related pages.
2026-02-05 15:22:44 -08:00
James Ritchie 3bb9aac014 Fix(webapp): Prevent big numbers on Queue page from jumping around when animating (#3007) 2026-02-05 07:45:40 -08:00
Saadi Myftija 283f88b203 feat(webapp): add triggered via field to deployment details page (#2850)
Display the deployment trigger source (CLI, CI/CD, Dashboard, GitHub
Integration) with appropriate icons on the deployment details page. The
triggeredVia field was already in the database but not displayed.

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-05 15:24:23 +01:00
James Ritchie c55af7bead fix(webapp): ask ai button missing tooltip (#2964)
Fixes
- the tooltip not displaying on the AskAI button in the side menu
- incorrect AskAI button heights
- Small UI tweaks
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2964">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Mihai Popescu <mihai.popescu.dev@gmail.com>
2026-02-05 03:17:18 +02:00
Iss db4fb9eeef docs: Add Hookdeck example (#3005)
Add documentation for integrating Hookdeck with Trigger.dev to receive
webhooks and forward them to Trigger.dev tasks.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3005">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 16:50:59 -08:00
Iss c0595700f8 docs: clarify .env.local loading and idempotency key reset scope (#2996)
Document that .env.local is automatically loaded during dev, and clarify
that backend-triggered idempotency keys should be reset with global
scope
2026-02-04 09:37:35 -05:00
Iss 6a45f5623b docs: multi-tenant applications and concurrency limits (#2961)
Adds an example of multi-tenant applications as alternative to
project/limit increase, and more info about queue times and concurrency
2026-02-04 09:33:05 -05:00
Iss 104f720f6f docs(troubleshooting): add COULD_NOT_FIND_EXECUTOR error and IPv4 support (#2950)
Document COULD_NOT_FIND_EXECUTOR error with dynamic imports and IPv4
database connection limitation in troubleshooting guide.
2026-02-04 09:30:51 -05:00
Iss e017913021 docs(self-hosting): added graphile worker troubleshooting to docs (#2883)
Add troubleshooting documentation for graphile worker schema migration
failures and PostgreSQL SSL certificate issues that prevent worker
initialization.
2026-02-04 09:29:01 -05:00
Matt Aitken 7781e2aad1 docs: usage function examples were missing the imports (#2830)
Closes #2828
2026-02-04 14:05:59 +00:00
Saadi Myftija 8e0034484c feat(supervisor): project-based scheduling affinity for image cache locality (#2995)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
Adds optional pod affinity so pods from the same project prefer
scheduling on the same node. This can help improve image cache hit
rates; subsequent pods benefit from already-pulled image layers,
reducing startup time.

Complements the built-in ImageLocality scheduler plugin by helping
during burst scheduling scenarios. Pod affinity sees scheduled pods
immediately, while ImageLocality only sees images after they're fully
pulled.

Configuration:
- `KUBERNETES_PROJECT_AFFINITY_ENABLED` - Enable/disable (default:
false)
- `KUBERNETES_PROJECT_AFFINITY_WEIGHT` - Scheduler weight 1-100
(default: 50)
- `KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY` - Topology key (default:
kubernetes.io/hostname)

Uses soft (preferred) affinity so pods always schedule even if preferred
node is full.

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2995">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 14:39:47 +01:00
Eric Allam b72cacc671 feat(debounce): add maxDelay option to limit total debounce time (#2984) 2026-02-02 20:15:06 +00:00
Mihai Popescu 1ccb8c186f changed schema id (#2983)
Fixed duplicate schema id for clickhouse
2026-02-02 02:43:39 +02:00
nicktrn 279102c17c fix(cli): reject execute() immediately when child process is dead (#2978)
## Summary
- When a child process crashes and a retry (`RETRY_IMMEDIATELY`) is
attempted on the same `TaskRunProcess`, `execute()` hangs forever
because the IPC send is silently skipped and the attempt promise can
never resolve
- This caused runner pods to stay up indefinitely with no heartbeats or
polls
- Fix: reject the attempt promise immediately when the child is not
connected, so the controller can proceed to warm start or exit

## Test plan
- [x] Added `taskRunProcess.test.ts` — verifies `execute()` rejects
promptly instead of hanging when the child process is dead
- [x] Deploy and verify no more stuck runner pods accumulate over time
2026-01-30 16:44:46 +00:00
Eric Allam b221719c09 chore(repo): fixed missing dependency in pnpm lockfile (#2976)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2976">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 10:02:30 +00:00
Matt Aitken e6861f4fe4 Fix: run page logs keep refreshing when a run finishes (#2971)
Closes #2798

When a run finished the logs UI could get stuck and so be pending and
never update again. If you did a hard reload it would be correct.

This happened because when we insert a log/span we ping Redis which
causes a reload of the UI. However there was a race condition – the
insert into ClickHouse can take a while so we were refreshing the UI too
early. Then never refreshing it again.
  
Changes
- Send refresh pings every 5s to keep run page logs live
- Throttle updates so the run UI is never updated more than once per
second
- Stop auto-reloading when a run has been completed for >= 30s
- Add type inference improvements for the throttle function
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2971">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 09:47:56 +00:00
Matt Aitken bc7ce78103 fix(sdk): export AnyOnStartAttemptHookFunction type (#2966)
Export AnyOnStartAttemptHookFunction type to allow defining
onStartAttempt hooks for individual tasks.

https://claude.ai/code/session_018jgSVcFtKVyv65ktGNQFFq
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2966">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-30 09:17:37 +00:00
Eric Allam 9937823a7f Standardize @types/node to version 20.14.14 across monorepo (#2970)
##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Description

This PR standardizes the `@types/node` dependency across the entire
monorepo to version `20.14.14`. Previously, different packages were
using different versions (ranging from 12.20.55 to 22.13.9), which could
cause type conflicts and inconsistencies.

### Changes Made

1. **tsconfig.json** - Added `"node"` to the `types` array in
`apps/webapp/tsconfig.json` to ensure Node.js types are properly
recognized
2. **package.json overrides** - Added `@types/node` version override to
`20.14.14` in the root `package.json`
3. **pnpm-lock.yaml** - Updated lock file to reflect the standardized
version across all packages and their dependencies
4. **Fixture package.json** - Updated
`packages/cli-v3/e2e/fixtures/emit-decorator-metadata/package.json` to
use the standardized version

This ensures consistent type definitions across the monorepo and
prevents version mismatches that could lead to type errors or unexpected
behavior.

---

## Testing

- Verified that all package references to `@types/node` now point to
version `20.14.14`
- Confirmed that the lock file properly reflects the override across all
transitive dependencies
- Ensured TypeScript configuration includes Node.js types for proper
type checking

---

## Changelog

- Standardized `@types/node` to version `20.14.14` across all packages
in the monorepo
- Added `"node"` to TypeScript compiler types in webapp configuration
- Updated all package dependencies to use the consistent version through
pnpm overrides

💯

https://claude.ai/code/session_018eqp2LvvErkFSN9oK5xBh1
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2970">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:35 +00:00
Eric Allam 3925f8cc49 fix(core): vendor superjson to fix ESM/CJS compatibility (#2949)
Bundle superjson and its dependency (copy-anything) during build to
avoid
ERR_REQUIRE_ESM errors on Node.js versions that don't support
require(ESM)
by default (< 22.12.0) and AWS Lambda which intentionally disables it.

- Add scripts/bundle-superjson.mjs to bundle superjson with esbuild
- Update build script to bundle vendor files before tshy compilation
- Move superjson from dependencies to devDependencies
- Update imports to use vendored bundles

Fixes #2937
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2949">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:02 +00:00
Iss 01208fde27 chore(docs): Add playwright workaround (#2973)
Adds a workaround to playwright to browser download failures based on
this GH issue: https://github.com/triggerdotdev/trigger.dev/issues/2440
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2973">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 08:43:23 +00:00
Eric Allam 5e049cde3a fix(run-engine): avoid NAPI string overflow in getExecutionSnapshotsSince by only fetching waitpoints for latest snapshot (#2972) 2026-01-29 19:03:54 +00:00
Matt Aitken 72c357125b Query enabled via feature flag (#2968)
- Renamed FeatureFlag functions to be singular where it makes sense.
- Added function to handle multiple feature flags
- canAccessQuery now checks the global feature flag and environment
variable as well
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2968">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 15:30:56 +00:00
DKP 0674d74bbb Added Trigger.dark theme to the docs (#2967)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2967">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:59:27 +00:00
DKP 9e08712749 Add building with ai/skills pages and updated intro (#2962)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2962">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:55:43 +00:00
Matt Aitken f53db6fd16 Query: time limits, performance improvements, styling (#2953)
Summary
- Query: add time limits, performance improvements, and styling updates

Changes
- Add ClickHouse output_text and error_text columns with indexes
- Automatically use _text columns for JSON based on query pattern;
support JSON column data prefixes
- Add idempotency key and scope columns
- Add enforcedWhereClause for tenant and time restrictions, instead of
the old tenant stuff.
- Implement basic time filter limiting and set default time period based
on plan; show message when results are clipped
- UX: resizable code area (including vertical splits), collapsible
sidebar, fix table/chart vertical sizing, max height for chart legend in
fullscreen
- Styling and UI tweaks: improved chart legend styling, more chart
colours, thinner line chart stroke, pricing callout color, improved
layout for callouts
- Features: generate and save AI titles
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2953">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:02:47 +00:00
Oskar Otwinowski c0b86efbd3 feat(webapp): Add MiddleTruncate component for long task names (#2946)
Closes #

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

Tested the MiddleTruncate component in the TasksDropdown by:
1. Verifying that long task names (e.g.,
"namespace:category:subcategory:task-name") are truncated in the middle
2. Confirming the full text appears in a tooltip on hover
3. Testing responsive behavior - truncation adjusts when the container
is resized
4. Verifying that short task names that fit within the container are
displayed in full without truncation

---

## Changelog

Added a new `MiddleTruncate` primitive component that intelligently
truncates text in the middle while preserving the beginning and end
portions. This is particularly useful for long hierarchical identifiers
like task slugs.

**Key features:**
- Truncates text in the middle with an ellipsis (…) when it exceeds
available width
- Shows full text in a tooltip on hover when truncated
- Responsive - recalculates truncation on container resize using
ResizeObserver
- Maintains minimum character visibility (4 chars minimum on each side
for readability)
- Integrated into TasksDropdown to handle long task names

**Changes:**
- Created new `MiddleTruncate.tsx` component with binary search
algorithm for optimal character distribution
- Updated TasksDropdown to use MiddleTruncate for task slug display
- Increased TasksDropdown popover width from 240px to 360px to provide
better space for truncated text

---

## Screenshots
💯


https://github.com/user-attachments/assets/a7a2191a-2e36-437e-ab3f-517fe7620b93


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2946">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 12:51:58 +01:00
Mihai Popescu e29e1c86d9 Fix/tri 7032 logs page feedback (#2947)
##  Changes
### UI & UX
- Normalized log level display across table and detail view
- Fixed table header scroll behavior and sidebar positioning
- Improved loading state with taller segment and disabled resizing
- Added "no more logs" message with count
- Enhanced keyboard shortcuts
### Filtering & Search
- Streamlined filters: RunId and Task only (removed run filters)
- Side panel closes when filters change
- Fixed logs from previous search remaining in table
- Fixed table scroll position when changing filters
### Backend
- Added performance indexes on message and attributes
(`014_add_task_runs_v2_search_indexes.sql`)
- Added DEBUG level logging by default
- Removed internal logs from display
- Fixed ServiceValidationError forwarding to frontend
  - Removed v1 logs API support
2026-01-29 12:47:59 +02:00
James Ritchie 34203d6a6f Fix(webapp) prevent incidents url being called every frame (#2956) 2026-01-27 18:33:36 +00:00
James Ritchie d4e4fbd7fc fix(webapp): Truncate long branch names to prevent breaking the onboarding layout (#2954)
### Before
<img width="1736" height="964" alt="CleanShot 2026-01-21 at 20 11 08@2x"
src="https://github.com/user-attachments/assets/76c6ed42-d2a0-4212-aaad-6fb848025c85"
/>

### After
<img width="1776" height="1032" alt="CleanShot 2026-01-27 at 17 32
47@2x"
src="https://github.com/user-attachments/assets/030ba26e-6aad-4232-a9ff-f2fde9931f8c"
/>

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2954">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 18:26:43 +00:00
James Ritchie 49de105862 Feat(webapp): collapsible side menu (#2939)
- Better separation between main feature pages and manage pages
- Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button
- Collapsed state persisted to database (survives page refresh)
- All menu items show tooltips when collapsed
- Smooth animations using Framer Motion and CSS transitions
- Impersonation-safe (preferences not modified when impersonating)


https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 17:13:21 +00:00
DKP 5fb9cc36bc fix(security): upgrade CLI deps and add overrides (#2952)
- Upgrade @modelcontextprotocol/sdk 1.24.0 → 1.25.2 (CVE-2026-0621
ReDoS)
- Upgrade tar 7.4.3 → 7.5.4+ (CVE-2026-23950 race condition)
- Add pnpm overrides for transitive deps:
  - qs <6.14.0 → 6.14.0 (CVE-2025-15284 DoS)
  - systeminformation <5.27.14 → 5.27.14 (CVE-2025-68154 cmd injection)
  - lodash <4.17.23 → 4.17.23 (CVE-2025-13465 prototype pollution)

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-01-27 16:33:04 +00:00
nicktrn 70c8d6d14b fix(react-hooks): prevent onComplete from firing prematurely when stream disconnects (#2929)
## Summary

Fixes #2856 - The `onComplete` callback in `useRealtimeRun` was firing prematurely

## Root Cause

The callback was triggered when the long-poll stream ended, regardless
of whether the run had actually completed. Reverse proxies often close
idle connections, causing the stream to end prematurely. In this case it
was caused by fetch abort due to React strict mode.

## Fix

Changed the condition from checking if `run` exists to checking if
`run?.finishedAt` exists, ensuring `onComplete` only fires when the run
has reached a terminal state.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <nicktrn@users.noreply.github.com>
2026-01-27 16:21:56 +00:00
Eric Allam eeab6bdeac fix(run-engine): fix queue cache memory leak and replace MemoryStore with LRU cache (#2945)
- Fix memory leak in RunAttemptSystem queue cache - was keying by runId
instead of queue identifier
- Replace `@unkey/cache` MemoryStore with new LRUMemoryStore for O(1)
operations and better memory bounds

## Problem

### Cache Key Bug
The queue cache in `#resolveTaskRunExecutionQueue` was keyed by `runId`,
creating one cache entry per run instead of per queue. With 1-2 hour
TTLs and 5000 entry soft cap, these accumulated causing memory growth.

### MemoryStore Performance
The `@unkey/cache` MemoryStore uses O(n) synchronous iteration for
eviction, blocking the event loop at high throughput.

## Solution

### Cache Key Fix
Changed cache key from `params.runId` to queue identifier:

```typescript
const cacheKey = params.lockedQueueId ?? `${params.runtimeEnvironmentId}:${params.queueName}`;
```

LRU Cache

Created LRUMemoryStore adapter using lru-cache package:
- O(1) get/set/delete operations
- Strict memory bounds (hard max vs soft cap)
- No event loop blocking

Test Results:

| Metric | Before Fix | After Fix |
|---|---|---|
| Queue cache entries (per 1000 runs) | ~1000 | 1 |
| Old space growth | 32.27 MB | 5.45 MB |
| Heap growth | 7.21 MB (3.9%) | 4.81 MB (2.6%) |


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2945">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-26 22:23:23 +00:00
Matt Aitken 825219a2f4 Fix: requeue a run if the DB is unavailable during dequeuing (#2938)
In the DequeueSystem if the database is unavailable we were dequeuing
from Redis and then failing to requeue in the error catcher – this was
because the requeuing required DB access.

Now if in the `catch` we encounter a DB error we requeue directly using
Redis, putting it back in the queue.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2938">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-25 19:23:04 +00:00
bharath kumar b143027d95 Fix/sdk stream root fallback (#2874) 2026-01-25 06:53:26 +00:00
Eric Allam 409388365e fix(fair-queue): ensure concurrency is released when a message reaches visibility timeout to prevent concurrency leaks (#2907)
## Summary

Fixes a concurrency leak in the batch queue where visibility timeout
reclaims do not release concurrency slots.

**The bug:** When a message visibility timeout expires (60s),
`reclaimTimedOut` puts the message back in the queue but does NOT
release the concurrency slot. The messageId stays in the concurrency set
(`engine:batch:concurrency:tenant:{envId}`), counting against the tenant
limit even though the message is no longer in-flight.

This causes:
1. Tenant appears at capacity when checking `SCARD >= limit`
2. New messages get released back to queue instead of being processed
3. Messages stuck in infinite loop, master queue grows indefinitely

**The fix:**
- Modified `reclaimTimedOut` to capture message data (including
tenantId) BEFORE releasing from in-flight
- Returns `ReclaimedMessageInfo[]` with messageId, queueId, tenantId,
and metadata
- `#reclaimTimedOutMessages` now iterates over reclaimed messages and
calls `concurrencyManager.release()` for each

## Test plan

- [x] Added test: `should return reclaimed message info with tenantId
for concurrency release`
- [x] Added test: `should return empty array when no messages have timed
out`
- [x] Added test: `should reclaim multiple timed-out messages and return
all their info`
- [x] Updated `raceConditions.test.ts` for new return type
- [x] All tests passing
- [ ] Monitor production after deploy for concurrency leak recurrence

refs TRI-7049
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2907">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 13:06:35 +00:00
Gautam Singh fe5178f3e8 feat(webapp): add priority to task test and replay options (#2936)
Closes #2934

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

1. make sure there are no worker connected or the queue is paused.
2. run hello world tasks and assign priority
3. notice the priority value in task context.
4. try running a replay for task with priority
5. notice the context priority
---

## Changelog

_[Short description of what has changed]_
Added option to set priority on task test and replay dialog
---

## Screenshots

_[Screenshots]_
<img width="953" height="804" alt="image"
src="https://github.com/user-attachments/assets/d452b872-6c05-4a9e-a1e3-f6a283bb363d"
/>
<img width="958" height="886" alt="image"
src="https://github.com/user-attachments/assets/8f9f3096-da99-4d81-a9c5-2f3004b89ceb"
/>
<img width="1022" height="736" alt="image"
src="https://github.com/user-attachments/assets/233e01ac-a42b-4b4f-ac3e-e9b18638b971"
/>


💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2936">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 09:10:14 +00:00
Iss a3f1eb2361 chore(docs): Admin requirements for creating worker groups (#2925)
Adds documentation for creating additional worker groups via the admin
API endpoint, including how to make users admin (new vs existing users),
and clarifies that ADMIN_EMAILS only applies on signup.
2026-01-24 09:00:50 +00:00
Iss ab4b50b95a chore(docs): Add clarifications for delay semantics and payload handling (#2928)
Adds documentation notes clarifying that:
- delayed runs execute on the current deployment version
- guidance for Date objects in payloads
- static IP availability
- version locking behavior for delayed runs
2026-01-24 08:59:55 +00:00
Eric Allam 1859fd0283 chore(docs): Improvements to the idempotency key docs for the user expored itempotency key changes (#2910) 2026-01-23 18:07:12 +00:00
597 changed files with 55946 additions and 7951 deletions
+13
View File
@@ -0,0 +1,13 @@
---
paths:
- "internal-packages/database/**"
---
# Database Migration Safety
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
- Never drop columns or tables without explicit approval.
- New code should target `RunEngineVersion.V2` only.
+14
View File
@@ -0,0 +1,14 @@
---
paths:
- "docs/**"
---
# Documentation Writing Rules
- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional).
- After creating a new page, add it to `docs.json` navigation under the correct group.
- Use Mintlify components: `<Note>`, `<Warning>`, `<Info>`, `<Tip>`, `<CodeGroup>`, `<Expandable>`, `<Steps>`/`<Step>`.
- Code examples should be complete and runnable where possible.
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
- Keep paragraphs short. Use headers to break up content.
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
+33
View File
@@ -0,0 +1,33 @@
---
paths:
- "apps/webapp/app/v3/**"
---
# Legacy V1 Engine Code in `app/v3/`
The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code.
## V1-Only Files - Never Modify
- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub)
- `legacyRunEngineWorker.server.ts` (V1 background job worker)
- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering)
- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation)
- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer)
- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer)
## V1/V2 Branching Pattern
Some services act as routers that branch on `RunEngineVersion`:
- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2
- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2
When editing these shared services, only modify V2 code paths.
## V2 Modern Stack
- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine)
- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker)
- **Queue operations**: RunQueue inside run-engine (not MarQS)
- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts`
- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`
+12
View File
@@ -0,0 +1,12 @@
---
paths:
- "packages/**"
---
# Public Package Rules
- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add`
- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval.
- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`).
- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
- Test changes using `references/hello-world` reference project.
+23
View File
@@ -0,0 +1,23 @@
---
paths:
- "apps/**"
---
# Server App Changes
When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
```bash
cat > .server-changes/descriptive-name.md << 'EOF'
---
area: webapp
type: fix
---
Brief description of what changed and why.
EOF
```
- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type**: `feature` | `fix` | `improvement` | `breaking`
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
+28
View File
@@ -0,0 +1,28 @@
name: Vouch Request
description: Request to be vouched as a contributor
labels: ["vouch-request"]
body:
- type: markdown
attributes:
value: |
## Vouch Request
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. PRs from unvouched users are automatically closed.
To get vouched, fill out this form. A maintainer will review your request and vouch for you by commenting on this issue.
- type: textarea
id: context
attributes:
label: Why do you want to contribute?
description: Tell us a bit about yourself and what you'd like to work on.
placeholder: "I'd like to fix a bug I found in..."
validations:
required: true
- type: textarea
id: prior-work
attributes:
label: Prior contributions or relevant experience
description: Links to previous open source work, relevant projects, or anything that helps us understand your background.
placeholder: "https://github.com/..."
validations:
required: false
+16
View File
@@ -0,0 +1,16 @@
# Vouched contributors for Trigger.dev
# See: https://github.com/mitchellh/vouch
#
# Org members
0ski
D-K-P
ericallam
matt-aitken
mpcgrid
myftija
nicktrn
samejr
isshaddad
# Outside contributors
gautamsi
capaj
+30 -9
View File
@@ -7,6 +7,7 @@ on:
paths:
- "packages/**"
- ".changeset/**"
- ".server-changes/**"
- "package.json"
- "pnpm-lock.yaml"
@@ -50,7 +51,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update PR title with version
- name: Update PR title and enhance body
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -61,6 +62,15 @@ jobs:
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
# Enhance the PR body with a clean, deduplicated summary
RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body')
ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION")
if [ -n "$ENHANCED_BODY" ]; then
gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \
-X PATCH \
-f body="$ENHANCED_BODY"
fi
fi
update-lockfile:
@@ -88,15 +98,26 @@ jobs:
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
- name: Commit and push lockfile
- name: Clean up consumed .server-changes/ files
run: |
set -e
if git diff --quiet pnpm-lock.yaml; then
echo "No lockfile changes"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore: update lockfile for release"
shopt -s nullglob
files=(.server-changes/*.md)
for f in "${files[@]}"; do
if [ "$(basename "$f")" != "README.md" ]; then
git rm --ignore-unmatch "$f"
fi
done
- name: Commit and push lockfile + server-changes cleanup
run: |
set -e
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
if ! git diff --cached --quiet; then
git commit -m "chore: update lockfile and clean up .server-changes/ for release"
git push origin changeset-release/main
else
echo "No changes to commit"
fi
+69
View File
@@ -0,0 +1,69 @@
name: 📝 CLAUDE.md Audit
on:
pull_request:
types: [opened, ready_for_review, synchronize]
paths-ignore:
- "docs/**"
- ".changeset/**"
- ".server-changes/**"
- "**/*.md"
- "references/**"
concurrency:
group: claude-md-audit-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
audit:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
use_sticky_comment: true
claude_args: |
--max-turns 15
--allowedTools "Read,Glob,Grep,Bash(git diff:*)"
prompt: |
You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating.
## Your task
1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory.
3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider:
- New files/directories that aren't covered by existing documentation
- Changed architecture or patterns that contradict current CLAUDE.md guidance
- New dependencies, services, or infrastructure that Claude should know about
- Renamed or moved files that are referenced in CLAUDE.md
- Changes to build commands, test patterns, or development workflows
## Response format
If NO updates are needed, respond with exactly:
✅ CLAUDE.md files look current for this PR.
If updates ARE needed, respond with a short list:
📝 **CLAUDE.md updates suggested:**
- `path/to/CLAUDE.md`: [what should be added/changed]
- `.claude/rules/file.md`: [what should be added/changed]
Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions.
Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns).
Do NOT suggest creating new CLAUDE.md files - only updates to existing ones.
+4
View File
@@ -29,3 +29,7 @@ jobs:
with:
package: cli-v3
secrets: inherit
sdk-compat:
uses: ./.github/workflows/sdk-compat.yml
secrets: inherit
+81 -2
View File
@@ -111,7 +111,7 @@ jobs:
uses: changesets/action@v1
with:
publish: pnpm run changeset:release
createGithubReleases: true
createGithubReleases: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -122,7 +122,19 @@ jobs:
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
# this triggers the publish workflow for the docker images
- name: Create unified GitHub release
if: steps.changesets.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
VERSION="${{ steps.get_version.outputs.package_version }}"
node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md
gh release create "v${VERSION}" \
--title "trigger.dev v${VERSION}" \
--notes-file /tmp/release-body.md \
--target main
- name: Create and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
@@ -130,6 +142,73 @@ jobs:
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
# Trigger Docker builds directly via workflow_call since tags pushed with
# GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation).
publish-docker:
name: 🐳 Publish Docker images
needs: release
if: needs.release.outputs.published == 'true'
uses: ./.github/workflows/publish.yml
secrets: inherit
with:
image_tag: v${{ needs.release.outputs.published_package_version }}
# After Docker images are published, update the GitHub release with the exact GHCR tag URL.
# The GHCR package version ID is only known after the image is pushed, so we query for it here.
update-release:
name: 🔗 Update release Docker link
needs: [release, publish-docker]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
packages: read
steps:
- name: Update GitHub release with Docker image link
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
VERSION="${{ needs.release.outputs.published_package_version }}"
TAG="v${VERSION}"
# Query GHCR for the version ID matching this tag
VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \
/orgs/triggerdotdev/packages/container/trigger.dev/versions \
--jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \
| head -1)
if [ -z "$VERSION_ID" ]; then
echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update"
exit 0
fi
DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}"
GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev"
# Get current release body and replace the generic link with the tag-specific one.
# Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that
# already have a version ID appended (idempotent on re-runs).
gh release view "${TAG}" --json body --jq '.body' > /tmp/release-body.md
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
gh release edit "${TAG}" --notes-file /tmp/release-body.md
# Dispatch changelog entry creation to the marketing site repo.
# Runs after update-release so the GitHub release body already has the exact Docker image URL.
dispatch-changelog:
name: 📝 Dispatch changelog PR
needs: [release, update-release]
if: needs.release.outputs.published == 'true'
runs-on: ubuntu-latest
steps:
- uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CROSS_REPO_PAT }}
repository: triggerdotdev/trigger.dev-site-v3
event-type: new-release
client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}"}'
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
prerelease:
name: 🧪 Prerelease
+178
View File
@@ -0,0 +1,178 @@
name: "🔌 SDK Compatibility Tests"
permissions:
contents: read
on:
workflow_call:
jobs:
node-compat:
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node: ["20.20", "22.12"]
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
shell: bash
run: pnpm run build --filter '@trigger.dev/sdk^...'
- name: 🔨 Build SDK
shell: bash
run: pnpm run build --filter '@trigger.dev/sdk'
- name: 🧪 Run SDK Compatibility Tests
shell: bash
run: pnpm --filter @internal/sdk-compat-tests test
bun-compat:
name: "Bun Runtime"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 🥟 Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 🧪 Run Bun Compatibility Test
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
run: bun run test.ts
deno-compat:
name: "Deno Runtime"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 🦕 Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 🔗 Link node_modules for Deno fixture
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
run: ln -s ../../../../../node_modules node_modules
- name: 🧪 Run Deno Compatibility Test
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
run: deno run --allow-read --allow-env --allow-sys test.ts
cloudflare-compat:
name: "Cloudflare Workers"
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.20.0
cache: "pnpm"
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🔨 Build SDK dependencies
run: pnpm run build --filter @trigger.dev/sdk^...
- name: 🔨 Build SDK
run: pnpm run build --filter @trigger.dev/sdk
- name: 📥 Install Cloudflare fixture deps
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
run: pnpm install
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
run: npx wrangler deploy --dry-run --outdir dist
+22
View File
@@ -0,0 +1,22 @@
name: Vouch - Check PR
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
pull-requests: write
issues: read
jobs:
check-pr:
runs-on: ubuntu-latest
steps:
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
pr-number: ${{ github.event.pull_request.number }}
auto-close: true
require-vouch: true
env:
GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,24 @@
name: Vouch - Manage by Issue
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
jobs:
manage:
runs-on: ubuntu-latest
if: >-
contains(github.event.comment.body, 'vouch') ||
contains(github.event.comment.body, 'denounce') ||
contains(github.event.comment.body, 'unvouch')
steps:
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
with:
comment-id: ${{ github.event.comment.id }}
issue-id: ${{ github.event.issue.number }}
env:
GH_TOKEN: ${{ github.token }}
+5 -1
View File
@@ -15,6 +15,9 @@ out/
dist
packages/**/dist
# vendored bundles (generated during build)
packages/**/src/**/vendor
# Tailwind
apps/**/styles/tailwind.css
packages/**/styles/tailwind.css
@@ -64,4 +67,5 @@ apps/**/public/build
**/.claude/settings.local.json
.mcp.log
.mcp.json
.cursor/debug.log
.cursor/debug.log
ailogger-output.log
View File
+81
View File
@@ -0,0 +1,81 @@
# Server Changes
This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented.
## When to add a file
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
**Package-only PRs**: Just add a changeset as usual.
## File format
Create a markdown file with a descriptive name:
```
.server-changes/fix-batch-queue-stalls.md
```
With this format:
```markdown
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
```
### Fields
- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- **type** (required): `feature` | `fix` | `improvement` | `breaking`
### Description
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
## Lifecycle
1. Engineer adds a `.server-changes/` file in their PR
2. Files accumulate on `main` as PRs merge
3. The changeset release PR includes these in its summary
4. After the release merges, CI cleans up the consumed files
## Examples
**New feature:**
```markdown
---
area: webapp
type: feature
---
TRQL query language and the Query page
```
**Bug fix:**
```markdown
---
area: webapp
type: fix
---
Fix schedule limit counting for orgs with custom limits
```
**Improvement:**
```markdown
---
area: webapp
type: improvement
---
Use the replica for API auth queries to reduce primary load
```
+9
View File
@@ -31,6 +31,15 @@
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug opened test file",
"command": "pnpm run test -- ./${relativeFile}",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
+1 -1
View File
@@ -7,5 +7,5 @@
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": false
"chat.agent.maxRequests": 10000
}
+34 -9
View File
@@ -1,24 +1,49 @@
# Changesets
# Changesets and Server Changes
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm.
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention.
## Adding a changeset
## Adding a changeset (package changes)
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
## Release instructions (local only)
## Adding a server change (server-only changes)
Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
1. Run `pnpm run changeset:version`
2. Run `pnpm run changeset:release`
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type`: `feature` | `fix` | `improvement` | `breaking`
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
See `.server-changes/README.md` for full documentation.
## When to add which
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
## Release instructions (CI)
Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly:
- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm.
- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries.
- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main.
- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release.
## Pre-release instructions
+61 -202
View File
@@ -1,73 +1,47 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas.
## 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
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
pnpm run db:migrate # Run database migrations
pnpm run db:seed # Seed the database (required for reference projects)
# 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/*"
pnpm run dev --filter webapp # Run webapp (http://localhost:3030)
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages
```
### Testing
## 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)
pnpm run test --filter webapp # All tests for a package
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
pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file
pnpm run build --filter @internal/run-engine # May need to build deps first
```
Test files go next to source files (e.g., `MyService.ts` `MyService.test.ts`).
Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`).
#### Testcontainers for Redis/PostgreSQL
### 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 }) => {
/* ... */
});
redisTest("should use redis", async ({ redisOptions }) => { /* ... */ });
postgresTest("should use postgres", async ({ prisma }) => { /* ... */ });
containerTest("should use both", async ({ prisma, redisOptions }) => { /* ... */ });
```
### Changesets
## Changesets and Server Changes
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
@@ -77,227 +51,112 @@ 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
- **Never** select major without explicit approval
When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation.
## Architecture Overview
### Request Flow
User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state)
### 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.
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
- **apps/supervisor**: Manages task execution containers (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/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks
- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images
- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root).
- **packages/build** (`@trigger.dev/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
- **packages/redis-worker** (`@trigger.dev/redis-worker`): 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)
- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL)
- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries
- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management
- **internal-packages/redis**: Redis client creation utilities (ioredis)
- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers
- **internal-packages/schedule-engine**: Durable cron scheduling
- **internal-packages/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
### Legacy V1 Engine Code
The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker.
### Documentation
Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions.
### 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.
The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs.
## Docker Image Guidelines
When updating Docker image references in `docker/Dockerfile` or other container files:
When updating Docker image references:
- **Always use multiplatform/index digests**, not architecture-specific digests
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
## Database Migrations (PostgreSQL)
1. Edit `internal-packages/database/prisma/schema.prisma`
2. Create migration:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column"
```
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
- `_BackgroundWorkerToBackgroundWorkerFile`
- `_BackgroundWorkerToTaskQueue`
- `_TaskRunToTaskRunTag`
- `_WaitpointRunConnections`
- `_completedWaitpoints`
- `SecretStore_key_idx`
- Various `TaskRun` indexes unless you added them
4. Apply migration:
```bash
pnpm run db:migrate:deploy && pnpm run generate
```
### Index Migration Rules
- Indexes **must use CONCURRENTLY** to avoid table locks
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
## ClickHouse Migrations
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
2. Use Goose markers:
```sql
-- +goose Up
ALTER TABLE trigger_dev.your_table
ADD COLUMN new_column String DEFAULT '';
-- +goose Down
ALTER TABLE trigger_dev.your_table
DROP COLUMN new_column;
```
Follow naming conventions in `internal-packages/clickhouse/README.md`:
- `raw_` prefix for input tables
- `_v1`, `_v2` suffixes for versioning
- `_mv_v1` suffix for materialized views
- Architecture-specific digests cause CI failures on different build environments
- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant
## Writing Trigger.dev Tasks
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
```typescript
import { task } from "@trigger.dev/sdk";
// Every task must be exported
export const myTask = task({
id: "my-task", // Unique ID
id: "my-task",
run: async (payload: { message: string }) => {
// Task logic - no timeouts
// Task logic
},
});
```
### 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.
The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes.
## Testing with hello-world Reference Project
First-time setup:
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
1. `pnpm run db:seed` to seed the database
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`
3. Authorize: `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
```
Running: `cd references/hello-world && pnpm exec trigger dev`
## 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
curl -s http://localhost:3030/healthcheck # Verify running
```
### 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
# Wait for "Local worker ready [node]"
```
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
)
mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"})
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`
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
+49 -1
View File
@@ -2,10 +2,25 @@
Thank you for taking the time to contribute to Trigger.dev. Your involvement is not just welcomed, but we encourage it! 🚀
Please take some time to read this guide to understand contributing best practices for Trigger.dev.
Please take some time to read this guide to understand contributing best practices for Trigger.dev. Note that we use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust, so you'll need to be vouched before opening a PR.
Thank you for helping us make Trigger.dev even better! 🤩
> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one.
## Getting vouched (required before opening a PR)
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.**
Before you open your first pull request, you need to be vouched by a maintainer. Here's how:
1. Open a [Vouch Request](https://github.com/triggerdotdev/trigger.dev/issues/new?template=vouch-request.yml) issue.
2. Tell us what you'd like to work on and share any relevant background.
3. A maintainer will review your request and vouch for you by commenting on the issue.
4. Once vouched, your PRs will be accepted normally.
If you're unsure whether you're already vouched, go ahead and open a PR — the check will tell you.
## Developing
The development branch is `main`. This is the branch that all pull
@@ -252,6 +267,39 @@ You will be prompted to select which packages to include in the changeset. Only
Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review.
## Adding server changes
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
Create a markdown file with a descriptive name:
```sh
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
---
area: webapp
type: fix
---
Speed up batch queue processing by removing stalls and fixing retry race
EOF
```
**Fields:**
- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
- `type` (required): `feature` | `fix` | `improvement` | `breaking`
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
**When to add which:**
| PR changes | What to add |
|---|---|
| Only packages (`packages/`) | Changeset |
| Only server (`apps/`) | `.server-changes/` file |
| Both packages and server | Just the changeset |
See `.server-changes/README.md` for more details.
## Troubleshooting
### EADDRINUSE: address already in use :::3030
+23
View File
@@ -1,5 +1,28 @@
## Guide on releasing a new version
### Automated release (v4+)
Releases are fully automated via CI:
1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes).
2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up).
3. When ready to release, merge the changeset release PR into `main`.
4. The [release.yml](./.github/workflows/release.yml) workflow automatically:
- Publishes all packages to npm
- Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4")
- Tags and triggers Docker image builds
- After Docker images are pushed, updates the GitHub release with the exact GHCR tag link
### What engineers need to do
- **Package changes**: Add a changeset with `pnpm run changeset:add`
- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`)
- **Mixed PRs**: Just the changeset is enough
See `CHANGESETS.md` for full details on changesets and server changes.
### Legacy release (v3)
1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge.
2. Pull the changes locally into main
3. Run `pnpm i` which will update the pnpm lock file with the new versions
View File
+1 -1
View File
@@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter coordinator build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle
FROM alpine AS cri-tools
+1 -1
View File
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter docker-provider build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle
FROM base AS runner
+1 -1
View File
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
COPY --from=dev-deps --chown=node:node /app/ .
COPY --chown=node:node turbo.json turbo.json
RUN pnpm run -r --filter kubernetes-provider build:bundle
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle
FROM base AS runner
+20
View File
@@ -0,0 +1,20 @@
# Supervisor
Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results.
## Key Directories
- `src/services/` - Core service logic
- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes)
- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots)
- `src/clients/` - Platform communication (webapp/coordinator)
- `src/env.ts` - Environment configuration
## Architecture
- **WorkloadManager**: Abstracts Docker vs Kubernetes execution
- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling
- **ResourceMonitor**: Tracks CPU/memory during execution
- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup
Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp.
+5
View File
@@ -112,6 +112,11 @@ const Env = z.object({
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
// Project affinity settings - pods from the same project prefer the same node
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),
@@ -120,7 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
affinity: this.#getNodeAffinity(opts.machine),
affinity: this.#getAffinity(opts.machine, opts.projectId),
terminationGracePeriodSeconds: 60 * 60,
containers: [
{
@@ -390,7 +390,21 @@ export class KubernetesWorkloadManager implements WorkloadManager {
return preset.name.startsWith("large-");
}
#getNodeAffinity(preset: MachinePreset): k8s.V1Affinity | undefined {
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
const nodeAffinity = this.#getNodeAffinityRules(preset);
const podAffinity = this.#getProjectPodAffinity(projectId);
if (!nodeAffinity && !podAffinity) {
return undefined;
}
return {
...(nodeAffinity && { nodeAffinity }),
...(podAffinity && { podAffinity }),
};
}
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined {
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
return undefined;
}
@@ -398,42 +412,64 @@ export class KubernetesWorkloadManager implements WorkloadManager {
if (this.#isLargeMachine(preset)) {
// soft preference for the large-machine pool, falls back to standard if unavailable
return {
nodeAffinity: {
preferredDuringSchedulingIgnoredDuringExecution: [
{
weight: 100,
preference: {
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "In",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
preferredDuringSchedulingIgnoredDuringExecution: [
{
weight: 100,
preference: {
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "In",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
],
},
},
],
};
}
// not schedulable in the large-machine pool
return {
nodeAffinity: {
requiredDuringSchedulingIgnoredDuringExecution: {
nodeSelectorTerms: [
{
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "NotIn",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
],
},
requiredDuringSchedulingIgnoredDuringExecution: {
nodeSelectorTerms: [
{
matchExpressions: [
{
key: "node.cluster.x-k8s.io/machinepool",
operator: "NotIn",
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
},
],
},
],
},
};
}
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
return undefined;
}
return {
preferredDuringSchedulingIgnoredDuringExecution: [
{
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
podAffinityTerm: {
labelSelector: {
matchExpressions: [
{
key: "project",
operator: "In",
values: [projectId],
},
],
},
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
},
},
],
};
}
}
+58
View File
@@ -0,0 +1,58 @@
# Webapp
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
## Key File Locations
- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts`
- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts`
- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts`
- **Prisma setup**: `app/db.server.ts`
- **Run engine config**: `app/v3/runEngine.server.ts`
- **Services**: `app/v3/services/**/*.server.ts`
- **Presenters**: `app/v3/presenters/**/*.server.ts`
## Route Convention
Routes use Remix flat-file convention with dot-separated segments:
`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger`
## Environment Variables
Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.**
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead:
- `realtimeClient.server.ts` (testable service, takes config as constructor arg)
- `realtimeClientGlobal.server.ts` (creates singleton with env config)
## Run Engine 2.0
The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2.
## Background Workers
Background job workers use `@trigger.dev/redis-worker`:
- `app/v3/commonWorker.server.ts`
- `app/v3/alertsWorker.server.ts`
- `app/v3/batchTriggerWorker.server.ts`
Do NOT add new jobs using zodworker/graphile-worker (legacy).
## Real-time
- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts`
- Electric SQL: Powers real-time data sync for the dashboard
## Legacy V1 Code
The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy:
- `app/v3/marqs/` (old MarQS queue system)
- `app/v3/legacyRunEngineWorker.server.ts`
- `app/v3/services/triggerTaskV1.server.ts`
- `app/v3/services/cancelTaskRunV1.server.ts`
- `app/v3/authenticatedSocketConnection.server.ts`
- `app/v3/sharedSocketConnection.ts`
Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths.
+29
View File
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
</>
);
}
export function BetaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Beta
</Badge>
}
content="This feature is in Beta."
disableHoverableContent
/>
);
}
export function BetaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<BetaBadge />
</>
);
}
+39 -25
View File
@@ -5,6 +5,7 @@ import {
HandThumbUpIcon,
StopIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
import { useSearchParams } from "@remix-run/react";
import DOMPurify from "dompurify";
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
return routeMatch?.kapa.websiteId;
}
export function AskAI() {
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
const websiteId = useKapaWebsiteId();
@@ -54,21 +55,23 @@ export function AskAI() {
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
disabled
className={isCollapsed ? "w-full justify-center" : ""}
>
<AISparkleIcon className="size-5" />
</Button>
}
>
{() => <AskAIProvider websiteId={websiteId} />}
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
</ClientOnly>
);
}
type AskAIProviderProps = {
websiteId: string;
isCollapsed?: boolean;
};
function AskAIProvider({ websiteId }: AskAIProviderProps) {
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>();
const [searchParams, setSearchParams] = useSearchParams();
@@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
}}
botProtectionMechanism="hcaptcha"
>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
>
<AISparkleIcon className="size-5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
Ask AI
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
<Button
variant="small-menu-item"
data-action="ask-ai"
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
hideShortcutKey
data-modal-override-open-class-ask-ai="true"
onClick={() => openAskAI()}
fullWidth={isCollapsed}
className={cn("h-full", isCollapsed && "justify-center")}
>
<AISparkleIcon className="size-5" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="flex items-center gap-2 text-xs"
>
Ask AI
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
<AskAIDialog
initialQuery={initialQuery}
isOpen={isOpen}
@@ -599,9 +599,9 @@ function DeploymentOnboardingSteps() {
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
<div className="mb-2 flex min-w-0 items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
<Header1 className="truncate">Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
@@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) {
baseProfile="tiny"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 2350 2314.8"
xmlSpace="preserve"
fill="currentColor"
@@ -0,0 +1,56 @@
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { LogLevel } from "./logs/LogLevel";
export function LogLevelTooltipInfo() {
return (
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
<div>
<Header3>Log Levels</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Structured logging helps you debug and monitor your tasks.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="TRACE" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Traces and spans representing the execution flow of your tasks.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="INFO" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="WARN" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="ERROR" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="DEBUG" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
</div>
);
}
+51 -10
View File
@@ -1,18 +1,17 @@
import { Keyboard } from "lucide-react";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Button } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetTrigger
} from "./primitives/SheetV3";
import { ShortcutKey } from "./primitives/ShortcutKey";
import { Button } from "./primitives/Buttons";
import { useState } from "react";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
export function Shortcuts() {
return (
@@ -26,8 +25,8 @@ export function Shortcuts() {
fullWidth
textAlignLeft
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
className="gap-x-0 pl-0.5"
iconSpacing="gap-x-0.5"
className="gap-x-0 pl-1.5"
iconSpacing="gap-x-1.5"
>
Shortcuts
</Button>
@@ -77,11 +76,16 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Ask AI">
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter">
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Toggle side menu">
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select filter">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
@@ -135,8 +139,8 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to next/previous run">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
@@ -158,6 +162,43 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Logs page</Header3>
<Shortcut name="Filter by task">
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter by run ID">
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Filter by level">
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select log level">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
to
</Paragraph>
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Close detail panel">
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Details tab">
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Run tab">
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="View full run">
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Metrics page</Header3>
<Shortcut name="Toggle fullscreen chart">
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
<Shortcut name="New schedule">
@@ -0,0 +1,30 @@
import { useFetcher } from "@remix-run/react";
import { useEffect, useRef } from "react";
import { useTypedLoaderData } from "remix-typedjson";
import type { loader } from "~/root";
export function TimezoneSetter() {
const { timezone: storedTimezone } = useTypedLoaderData<typeof loader>();
const fetcher = useFetcher();
const hasSetTimezone = useRef(false);
useEffect(() => {
if (hasSetTimezone.current) return;
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone && browserTimezone !== storedTimezone) {
hasSetTimezone.current = true;
fetcher.submit(
{ timezone: browserTimezone },
{
method: "POST",
action: "/resources/timezone",
encType: "application/json",
}
);
}
}, [storedTimezone, fetcher]);
return null;
}
@@ -1,7 +1,13 @@
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
// Lazy load streamdown components to avoid SSR issues
const StreamdownRenderer = lazy(() =>
@@ -13,13 +19,6 @@ const StreamdownRenderer = lazy(() =>
),
}))
);
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
@@ -179,21 +178,7 @@ export function AIQueryInput({
setThinking((prev) => prev + event.content);
break;
case "tool_call":
if (event.tool === "setTimeFilter") {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Setting time filter...")) {
return prev;
}
return prev + `\nSetting time filter...\n`;
});
} else {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Validating query...")) {
return prev;
}
return prev + `\nValidating query...\n`;
});
}
// Tool calls are handled silently — no UI text needed
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
@@ -262,13 +247,13 @@ export function AIQueryInput({
}, [error]);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col">
{/* Gradient border wrapper like the schedules AI input */}
<div
className="rounded-md p-px"
className="overflow-hidden rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-[5px] bg-background-bright">
<div className="overflow-hidden rounded-md bg-background-bright">
<form onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
@@ -297,10 +282,10 @@ export function AIQueryInput({
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-1.5"
className="pl-2"
iconSpacing="gap-1.5"
>
{mode === "edit" ? "Editing..." : "Generating..."}
{mode === "edit" ? "Editing" : "Generating"}
</Button>
) : (
<>
@@ -366,64 +351,60 @@ export function AIQueryInput({
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{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"
<div className="px-1">
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
{isLoading ? (
<Spinner className="size-4" />
) : lastResult === "success" ? (
<CheckIcon className="size-4 text-success" />
) : lastResult === "error" ? (
<XMarkIcon className="size-4 text-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking…"
: lastResult === "success"
? "Query generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
? "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>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
@@ -1,27 +1,20 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import SegmentedControl from "../primitives/SegmentedControl";
import { Button } from "../primitives/Buttons";
export type ChartType = "bar" | "line";
export type SortDirection = "asc" | "desc";
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
export interface ChartConfiguration {
chartType: ChartType;
xAxisColumn: string | null;
yAxisColumns: string[];
groupByColumn: string | null;
stacked: boolean;
sortByColumn: string | null;
sortDirection: SortDirection;
aggregation: AggregationType;
}
import {
type AggregationType,
type ChartConfiguration,
type SortDirection,
} from "../metrics/QueryWidget";
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
@@ -32,6 +25,7 @@ export const defaultChartConfig: ChartConfiguration = {
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
};
interface ChartConfigPanelProps {
@@ -155,8 +149,11 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
// Only re-run when the actual column structure changes, not on every config change.
// columnsKey (a string) is stable when columns match, so this won't re-fire
// unnecessarily when the same query is re-run with identical columns.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnsKey]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
@@ -239,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
}
return (
<div className={cn("flex flex-col gap-2 p-2", className)}>
<div className={cn("flex flex-col gap-3 p-2", className)}>
{/* Chart Type */}
<div className="flex flex-col gap-3">
<ConfigField label="Type">
<div className="flex items-center">
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-r-none border-b pl-1 pr-2",
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "bar" })}
LeadingIcon={BarChart}
leadingIconClassName={
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
Bar
</span>
</Button>
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-l-none border-b pl-1 pr-2",
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "line" })}
LeadingIcon={LineChart}
leadingIconClassName={
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
>
Line
</span>
</Button>
</div>
<SegmentedControl
name="chartType"
value={config.chartType}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<BarChart className="size-3" /> Bar
</span>
),
value: "bar",
},
{
label: (
<span className="flex items-center gap-1">
<LineChart className="size-3" /> Line
</span>
),
value: "line",
},
]}
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
/>
</ConfigField>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
@@ -329,60 +310,86 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
) : (
<div className="flex flex-col gap-1.5">
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
(col, index) => (
<div key={index} className="flex items-center gap-1">
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
updateConfig({ yAxisColumns: newColumns });
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
<div key={index} className="flex items-center gap-1">
{col && !config.groupByColumn && (
<SeriesColorPicker
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
onColorChange={(color) => {
updateConfig({
seriesColors: { ...config.seriesColors, [col]: color },
});
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
/>
)}
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
const updates: Partial<ChartConfiguration> = {};
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
// If the column name changed, migrate the color
const oldCol = newColumns[index];
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
const newSeriesColors = { ...config.seriesColors };
newSeriesColors[value] = newSeriesColors[oldCol];
delete newSeriesColors[oldCol];
updates.seriesColors = newSeriesColors;
}
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
updateConfig({ yAxisColumns: newColumns });
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
updateConfig({ ...updates, yAxisColumns: newColumns });
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
</div>
)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const removedCol = config.yAxisColumns[index];
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
// Clean up the color entry for the removed series
if (removedCol && config.seriesColors?.[removedCol]) {
const newSeriesColors = { ...config.seriesColors };
delete newSeriesColors[removedCol];
updates.seriesColors = newSeriesColors;
}
updateConfig(updates);
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Add another series button - only show when we have at least one series and not grouped */}
{config.yAxisColumns.length > 0 &&
@@ -439,9 +446,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Group By - disabled when multiple series are selected */}
<ConfigField label="Group by">
{config.yAxisColumns.length > 1 ? (
<span className="text-xs text-text-dimmed">
Not available with multiple series
</span>
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
) : (
<Select
value={config.groupByColumn ?? "__none__"}
@@ -510,9 +515,29 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="Sort direction">
<SortDirectionToggle
direction={config.sortDirection}
onChange={(direction) => updateConfig({ sortDirection: direction })}
<SegmentedControl
name="sortDirection"
value={config.sortDirection}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<IconSortAscending className="size-3" /> Asc
</span>
),
value: "asc",
},
{
label: (
<span className="flex items-center gap-1">
<IconSortDescending className="size-3" /> Desc
</span>
),
value: "desc",
},
]}
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
/>
</ConfigField>
)}
@@ -524,48 +549,55 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
{label && <span className="text-xs text-text-dimmed">{label}</span>}
{label && <span className="text-xs text-text-bright">{label}</span>}
{children}
</div>
);
}
function SortDirectionToggle({
direction,
onChange,
function SeriesColorPicker({
color,
onColorChange,
}: {
direction: SortDirection;
onChange: (direction: SortDirection) => void;
color: string;
onColorChange: (color: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex gap-1">
<button
type="button"
onClick={() => onChange("asc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "asc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Ascending"
>
Asc
</button>
<button
type="button"
onClick={() => onChange("desc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "desc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Descending"
>
Desc
</button>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex-shrink-0 rounded p-0.5 hover:bg-charcoal-700"
title="Change series color"
>
<span
className="block h-4 w-4 rounded-full border border-white/30"
style={{ backgroundColor: color }}
/>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1.5">
{CHART_COLORS_BY_HUE.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onColorChange(c);
setOpen(false);
}}
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
style={{ backgroundColor: c }}
title={c}
>
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
@@ -68,6 +68,9 @@ type CodeBlockProps = {
/** Search term to highlight in the code */
searchTerm?: string;
/** Whether to wrap the code */
wrap?: boolean;
};
const dimAmount = 0.5;
@@ -207,6 +210,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
fileName,
rowTitle,
searchTerm,
wrap = false,
...props
}: CodeBlockProps,
ref
@@ -215,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
const [copied, setCopied] = useState(false);
const [modalCopied, setModalCopied] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isWrapped, setIsWrapped] = useState(false);
const [isWrapped, setIsWrapped] = useState(wrap);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -1,40 +1,42 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { BarChart3, LineChart } from "lucide-react";
import { memo, useMemo } from "react";
import { createValueFormatter } from "~/utils/columnFormat";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
import { Callout } from "../primitives/Callout";
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
import { aggregateValues } from "../primitives/charts/aggregation";
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
import { getSeriesColor } from "./chartColors";
// Color palette for chart series
const CHART_COLORS = [
"#7655fd", // Primary purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
"#06b6d4", // Cyan
"#ec4899", // Pink
"#8b5cf6", // Violet
"#14b8a6", // Teal
"#f97316", // Orange
"#6366f1", // Indigo
];
function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
const MAX_SERIES = 50;
const MAX_SVG_ELEMENT_BUDGET = 6_000;
const MIN_DATA_POINTS = 100;
const MAX_DATA_POINTS = 500;
interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: ChartConfiguration;
/** The effective time range from the query filter (used to show the full x-axis period) */
timeRange?: { from: string; to: string };
fullLegend?: boolean;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
isLoading?: boolean;
}
interface TransformedData {
data: Record<string, unknown>[];
series: string[];
/** Total number of series before any truncation (equals series.length when no truncation) */
totalSeriesCount: number;
/** Raw date values for determining formatting granularity */
dateValues: Date[];
/** Whether the x-axis is date-based (continuous time scale) */
@@ -128,12 +130,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
}
}
/**
* Snap a millisecond value up to the nearest "nice" interval
*/
function snapToNiceInterval(ms: number): number {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
if (ms <= SECOND) return SECOND;
if (ms <= 5 * SECOND) return 5 * SECOND;
if (ms <= 10 * SECOND) return 10 * SECOND;
if (ms <= 15 * SECOND) return 15 * SECOND;
if (ms <= 30 * SECOND) return 30 * SECOND;
if (ms <= MINUTE) return MINUTE;
if (ms <= 5 * MINUTE) return 5 * MINUTE;
if (ms <= 10 * MINUTE) return 10 * MINUTE;
if (ms <= 15 * MINUTE) return 15 * MINUTE;
if (ms <= 30 * MINUTE) return 30 * MINUTE;
if (ms <= HOUR) return HOUR;
if (ms <= 2 * HOUR) return 2 * HOUR;
if (ms <= 4 * HOUR) return 4 * HOUR;
if (ms <= 6 * HOUR) return 6 * HOUR;
if (ms <= 12 * HOUR) return 12 * HOUR;
if (ms <= DAY) return DAY;
return ms;
}
/**
* Detect the most common interval between consecutive data points
* This helps us understand the natural granularity of the data
*/
function detectDataInterval(timestamps: number[]): number {
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
const sorted = [...timestamps].sort((a, b) => a - b);
const gaps: number[] = [];
@@ -151,25 +182,7 @@ function detectDataInterval(timestamps: number[]): number {
// We use the minimum gap as a heuristic for the data interval
const minGap = Math.min(...gaps);
// Round to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Snap to common intervals
if (minGap <= MINUTE) return MINUTE;
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
if (minGap <= HOUR) return HOUR;
if (minGap <= 2 * HOUR) return 2 * HOUR;
if (minGap <= 4 * HOUR) return 4 * HOUR;
if (minGap <= 6 * HOUR) return 6 * HOUR;
if (minGap <= 12 * HOUR) return 12 * HOUR;
if (minGap <= DAY) return DAY;
return minGap;
return snapToNiceInterval(minGap);
}
/**
@@ -193,20 +206,7 @@ function fillTimeGaps(
// If filling would create too many points, increase the interval to stay within limits
let effectiveInterval = interval;
if (estimatedPoints > maxPoints) {
effectiveInterval = Math.ceil(range / maxPoints);
// Round up to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
else effectiveInterval = 24 * HOUR;
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
}
// Create a map to collect values for each bucket (for aggregation)
@@ -256,17 +256,18 @@ function fillTimeGaps(
}
filledData.push(point);
} else {
// Create a zero-filled data point
const zeroPoint: Record<string, unknown> = {
// Create a null-filled data point so gaps appear in line/bar charts
// and legend aggregations (avg/min/max) skip these slots
const gapPoint: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: new Date(t),
__granularity: granularity,
__originalX: new Date(t).toISOString(),
};
for (const s of series) {
zeroPoint[s] = 0;
gapPoint[s] = null;
}
filledData.push(zeroPoint);
filledData.push(gapPoint);
}
}
@@ -365,22 +366,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
}
/**
* Formats a date for tooltips (always shows full precision)
* Formats a date for tooltips and legend headers.
* Always includes time when the data point has a non-midnight time,
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
* even when the axis labels only show the day.
* Seconds are shown whenever the granularity is "seconds" or the
* specific data point has non-zero seconds.
*/
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
// For shorter time ranges, include time
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
const hasSeconds = date.getSeconds() !== 0;
if (
granularity === "seconds" ||
(hasTime && granularity !== "months" && granularity !== "years")
) {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: granularity === "seconds" ? "2-digit" : undefined,
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
hour12: false,
});
}
// For longer ranges, just show date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@@ -438,7 +449,8 @@ function tryParseDate(value: unknown): Date | null {
*/
function transformDataForChart(
rows: Record<string, unknown>[],
config: ChartConfiguration
config: ChartConfiguration,
timeRange?: { from: string; to: string }
): TransformedData {
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
@@ -446,6 +458,7 @@ function transformDataForChart(
return {
data: [],
series: [],
totalSeriesCount: 0,
dateValues: [],
isDateBased: false,
xDataKey: xAxisColumn || "",
@@ -464,24 +477,37 @@ function transformDataForChart(
}
// Determine if X-axis is date-based (most values should be parseable as dates)
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
// When there are no results but a timeRange is provided, treat as date-based
const isDateBased =
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
// Detect granularity from the full time range when available, otherwise from data
const granularity = isDateBased
? timeRange
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
: detectTimeGranularity(dateValues)
: "days";
// For date-based axes, use a special key for the timestamp
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
// Calculate time domain and ticks for date-based axes
// When a timeRange is provided (from the query filter), use it so the chart
// shows the full requested period rather than just the range of returned data.
let timeDomain: [number, number] | null = null;
let timeTicks: number[] | null = null;
if (isDateBased && dateValues.length > 0) {
const timestamps = dateValues.map((d) => d.getTime());
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
// Raw min/max used for gap filling (without padding)
let rawMinTime = 0;
let rawMaxTime = 0;
if (isDateBased && (dateValues.length > 0 || timeRange)) {
const dataTimestamps = dateValues.map((d) => d.getTime());
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
// Add a small padding (2% on each side) so points aren't at the very edge
const padding = (maxTime - minTime) * 0.02;
timeDomain = [minTime - padding, maxTime + padding];
const padding = (rawMaxTime - rawMinTime) * 0.02;
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
// Generate evenly-spaced ticks across the entire range using nice intervals
timeTicks = generateTimeTicks(minTime, maxTime);
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
}
// Helper to format X value for categorical axes (non-date)
@@ -536,30 +562,57 @@ function transformDataForChart(
});
// Fill in gaps with zeros for date-based data
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
const effectiveMaxPoints = Math.max(
MIN_DATA_POINTS,
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
);
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
const maxRangeInterval =
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(
Math.max(dataInterval, minRangeInterval),
maxRangeInterval
);
data = fillTimeGaps(
data,
xDataKey,
yAxisColumns,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
aggregation,
effectiveMaxPoints
);
} else if (data.length > effectiveMaxPoints) {
data = data.slice(0, effectiveMaxPoints);
}
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
return {
data,
series: yAxisColumns,
totalSeriesCount: yAxisColumns.length,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
};
}
// With grouping: pivot data so each group value becomes a series
const yCol = yAxisColumns[0]; // Use first Y column when grouping
const groupValues = new Set<string>();
// For date-based, key by timestamp; otherwise by formatted string
// Collect all values for aggregation
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
// per-group totals so we can pick the top-N groups before building heavy data
// objects with thousands of keys.
const groupTotals = new Map<string, number>();
const groupedByX = new Map<
string | number,
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
@@ -568,29 +621,39 @@ function transformDataForChart(
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
const groupValue = String(row[groupByColumn] ?? "Unknown");
const yValue = toNumber(row[yCol]);
groupValues.add(groupValue);
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
}
const existing = groupedByX.get(xKey)!;
// Collect values for aggregation
if (!existing.values[groupValue]) {
existing.values[groupValue] = [];
}
existing.values[groupValue].push(yValue);
}
// Convert to array format with aggregation applied
const series = Array.from(groupValues).sort();
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
// downstream (data objects, gap filling, legend totals, SVG rendering).
const totalSeriesCount = groupTotals.size;
let series: string[];
if (groupTotals.size <= MAX_SERIES) {
series = Array.from(groupTotals.keys()).sort();
} else {
series = Array.from(groupTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_SERIES)
.map(([key]) => key)
.sort();
}
// Convert to array format with aggregation applied (only for kept series)
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
@@ -604,23 +667,44 @@ function transformDataForChart(
return point;
});
// Fill in gaps with zeros for date-based data
// Dynamic data-point budget based on the (already capped) series count
const effectiveMaxPoints = Math.max(
MIN_DATA_POINTS,
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
);
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval);
data = fillTimeGaps(
data,
xDataKey,
series,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
aggregation,
effectiveMaxPoints
);
} else if (data.length > effectiveMaxPoints) {
data = data.slice(0, effectiveMaxPoints);
}
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
return {
data,
series,
totalSeriesCount,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
};
}
function toNumber(value: unknown): number {
@@ -632,25 +716,6 @@ function toNumber(value: unknown): number {
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
/**
* Sort data array by a specified column
*/
@@ -700,8 +765,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
timeRange,
fullLegend = false,
onViewAllLegendItems,
isLoading = false,
legendScrollable = false,
}: QueryResultsChartProps) {
const {
xAxisColumn,
@@ -717,12 +785,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
const {
data: unsortedData,
series,
totalSeriesCount,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
@@ -733,13 +802,54 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
// Detect time granularity for the data
const timeGranularity = useMemo(
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
[dateValues]
// Sort series by descending total sum so largest appears at bottom of
// stacked charts and first in the legend
const sortedSeries = useMemo(() => {
if (series.length <= 1) return series;
const totals = new Map<string, number>();
for (const s of series) {
let total = 0;
for (const point of data) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
total += Math.abs(val);
}
}
totals.set(s, total);
}
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
}, [series, data]);
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
const visibleSeries = useMemo(
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
[sortedSeries]
);
// X-axis tick formatter for date-based axes
const seriesLimitCallout =
totalSeriesCount > series.length ? (
<div className="mt-1 px-2">
<Callout variant="warning">
{`Limited to the top ${
series.length
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
</Callout>
</div>
) : null;
// Detect time granularity — use the full time range when available so tick
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
// instead of just "16:00:00" when data is sparse)
const timeGranularity = useMemo(() => {
if (timeRange) {
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
}
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
}, [dateValues, timeRange]);
// X-axis tick formatter for date-based axes (pure no deduplication).
// Label deduplication is handled inside dateAxisTick below so that the
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
const xAxisTickFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: number) => {
@@ -748,20 +858,46 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, [isDateBased, timeGranularity]);
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Resolve the Y-axis column format for formatting
const yAxisFormat = useMemo(() => {
if (yAxisColumns.length === 0) return undefined;
const col = columns.find((c) => c.name === yAxisColumns[0]);
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
}, [yAxisColumns, columns]);
// Create dynamic Y-axis formatter based on data range and format
const yAxisFormatter = useMemo(
() => createYAxisFormatter(data, series, yAxisFormat),
[data, series, yAxisFormat]
);
// Create value formatter for tooltips and legend based on column format
const tooltipValueFormatter = useMemo(
() => createValueFormatter(yAxisFormat),
[yAxisFormat]
);
// Check if the group-by column has a runStatus customRenderType
const groupByIsRunStatus = useMemo(() => {
if (!groupByColumn) return false;
const col = columns.find((c) => c.name === groupByColumn);
return col?.customRenderType === "runStatus";
}, [groupByColumn, columns]);
// Build chart config for colors/labels
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
series.forEach((s, i) => {
sortedSeries.forEach((s, i) => {
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
const originalIndex = config.yAxisColumns.indexOf(s);
const colorIndex = originalIndex >= 0 ? originalIndex : i;
cfg[s] = {
label: s,
color: getSeriesColor(i),
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
};
});
return cfg;
}, [series]);
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -805,30 +941,125 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return [min, "auto"] as [number, string];
}, [data, series]);
// Validation
// Angle all date-based labels for consistent appearance and to avoid overlap
const xAxisAngle = isDateBased ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
// Check if the data would produce duplicate labels at the current granularity.
// Only use the custom tick renderer (with interval:0) when duplicates exist,
// otherwise let Recharts handle label spacing to avoid collisions.
const hasDuplicateLabels = useMemo(() => {
if (!isDateBased || !timeGranularity || data.length === 0) return false;
const labels = new Set<string>();
for (const point of data) {
const ts = point.__timestamp ?? point[xDataKey];
if (typeof ts === "number") {
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
}
}
return labels.size < data.length;
}, [isDateBased, timeGranularity, data, xDataKey]);
// Custom tick renderer for date-based axes: renders a tick mark alongside
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
// lastLabel is reset when Recharts starts a new render pass (index === 0).
const dateAxisTick = useMemo(() => {
if (!isDateBased || !xAxisTickFormatter) return undefined;
let lastLabel = "";
return (props: Record<string, unknown>) => {
const { x, y, payload, index } = props as {
x: number;
y: number;
payload: { value: number };
index: number;
};
// Reset dedup state at the start of each Recharts render pass
if (index === 0) lastLabel = "";
const formatted = xAxisTickFormatter(payload.value);
const label = formatted === lastLabel ? "" : formatted;
lastLabel = formatted;
// y is the tick text position, offset from the axis by tickMargin + internal padding
const axisY = (y as number) - 12;
if (label) {
return (
<g>
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#878C99"
strokeWidth={1}
/>
<text
x={x}
y={axisY}
dy={10}
fill="#878C99"
fontSize={11}
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
style={{ fontVariantNumeric: "tabular-nums" }}
transform={
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
}
>
{label}
</text>
</g>
);
}
// Small tick mark sitting on the axis baseline, pointing upward
return (
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#272A2E"
strokeWidth={1}
/>
);
};
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
// Validation — all hooks must be above this point
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
return (
<ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />
);
}
if (yAxisColumns.length === 0) {
return <EmptyState message="Select a Y-axis column to display the chart" />;
return (
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
);
}
if (rows.length === 0) {
return <EmptyState message="No data to display" />;
return <ChartBlankState icon={chartIcon} message="No data to display" />;
}
if (data.length === 0) {
return <EmptyState message="Unable to transform data for chart" />;
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
}
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Base x-axis props shared by all chart types
const baseXAxisProps = {
tickFormatter: xAxisTickFormatter,
...(dateAxisTick
? {
tick: dateAxisTick,
tickLine: false,
tickFormatter: undefined,
// Only force every tick to render when there are duplicates to de-duplicate;
// otherwise let Recharts auto-space to avoid label collisions
...(hasDuplicateLabels ? { interval: 0 } : {}),
}
: { tickFormatter: xAxisTickFormatter }),
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
@@ -838,13 +1069,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
// This properly represents time gaps between data points
const xAxisPropsForLine = isDateBased
? {
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
: baseXAxisProps;
// Bar charts always use categorical axis positioning
@@ -857,7 +1088,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
domain: yAxisDomain,
};
const showLegend = series.length > 0;
const showLegend = sortedSeries.length > 0;
if (chartType === "bar") {
return (
@@ -865,19 +1096,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
/>
</Chart.Root>
);
@@ -889,19 +1127,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stacked={stacked && series.length > 1}
stacked={stacked && visibleSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
@@ -909,9 +1154,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
});
/**
* Creates a Y-axis value formatter based on the data range
* Creates a Y-axis value formatter based on the data range and optional format hint
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
function createYAxisFormatter(
data: Record<string, unknown>[],
series: string[],
format?: ColumnFormatType
) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
@@ -928,6 +1177,46 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
const range = maxVal - minVal;
// Format-aware formatters
if (format === "bytes" || format === "decimalBytes") {
const divisor = format === "bytes" ? 1024 : 1000;
const units =
format === "bytes"
? ["B", "KiB", "MiB", "GiB", "TiB"]
: ["B", "KB", "MB", "GB", "TB"];
return (value: number): string => {
if (value === 0) return "0 B";
// Use consistent unit for all ticks based on max value
const i = Math.min(
Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))),
units.length - 1
);
const scaled = value / Math.pow(divisor, i);
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
};
}
if (format === "percent") {
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
}
if (format === "duration") {
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
}
if (format === "durationSeconds") {
return (value: number): string =>
formatDurationMilliseconds(value * 1000, { style: "short" });
}
if (format === "costInDollars" || format === "cost") {
return (value: number): string => {
const dollars = format === "cost" ? value / 100 : value;
return formatCurrencyAccurate(dollars);
};
}
// Default formatter
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
@@ -960,13 +1249,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
return Math.round(value).toString();
};
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center">
<Paragraph variant="small" className="text-text-dimmed">
{message}
</Paragraph>
</div>
);
}
+101 -3
View File
@@ -1,7 +1,7 @@
import { sql, StandardSQL } from "@codemirror/lang-sql";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import { EditorView } from "@codemirror/view";
import { EditorView, keymap } from "@codemirror/view";
import type { ViewUpdate } from "@codemirror/view";
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
import {
@@ -60,6 +60,54 @@ const defaultProps: TSQLEditorDefaultProps = {
schema: [],
};
// Toggle comment on current line or selected lines with -- comment symbol
const toggleLineComment = (view: EditorView): boolean => {
const { from, to } = view.state.selection.main;
const startLine = view.state.doc.lineAt(from);
// When `to` is exactly at the start of a line and there's an actual selection,
// the caret sits before that line — so exclude it by stepping back one position.
const adjustedTo = to > from && view.state.doc.lineAt(to).from === to ? to - 1 : to;
const endLine = view.state.doc.lineAt(adjustedTo);
// Collect all lines in the selection
const lines: { from: number; to: number; text: string }[] = [];
for (let i = startLine.number; i <= endLine.number; i++) {
const line = view.state.doc.line(i);
lines.push({ from: line.from, to: line.to, text: line.text });
}
// Determine action: if all non-empty lines are commented, uncomment; otherwise comment
const allCommented = lines.every((line) => {
const trimmed = line.text.trimStart();
return trimmed.length === 0 || trimmed.startsWith("--");
});
const changes = lines
.map((line) => {
const trimmed = line.text.trimStart();
if (trimmed.length === 0) return null; // skip empty lines
const indent = line.text.length - trimmed.length;
if (allCommented) {
// Remove comment: strip "-- " or just "--"
const afterComment = trimmed.slice(2);
const newText = line.text.slice(0, indent) + afterComment.replace(/^\s/, "");
return { from: line.from, to: line.to, insert: newText };
} else {
// Add comment: prepend "-- " to the line content
const newText = line.text.slice(0, indent) + "-- " + trimmed;
return { from: line.from, to: line.to, insert: newText };
}
})
.filter((c): c is { from: number; to: number; insert: string } => c !== null);
if (changes.length > 0) {
view.dispatch({ changes });
}
return true;
};
export function TSQLEditor(opts: TSQLEditorProps) {
const {
defaultValue = "",
@@ -133,6 +181,14 @@ export function TSQLEditor(opts: TSQLEditorProps) {
);
}
// Add keyboard shortcut for toggling comments
exts.push(
keymap.of([
{ key: "Cmd-/", run: toggleLineComment },
{ key: "Ctrl-/", run: toggleLineComment },
])
);
return exts;
}, [schema, linterEnabled]);
@@ -218,6 +274,9 @@ export function TSQLEditor(opts: TSQLEditorProps) {
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={editor}
onClick={() => {
view?.focus();
}}
onBlur={() => {
if (!onBlur) return;
if (!view) return;
@@ -225,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
@@ -279,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
);
}
// SQL keywords that legitimately appear before parentheses with a space
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
"IN",
"NOT",
"EXISTS",
"OVER",
"USING",
"VALUES",
"BETWEEN",
"LIKE",
"AND",
"OR",
"ON",
"SET",
"INTO",
"TABLE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"AS",
"FROM",
"WHERE",
"HAVING",
"JOIN",
"SELECT",
]);
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
let formatted = formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
// Remove that space for anything that isn't a SQL keyword
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
return match;
}
return `${name}(`;
});
return formatted;
}
@@ -1,23 +1,25 @@
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { IconFilter2, IconFilter2X, IconTable } from "@tabler/icons-react";
import { rankItem } from "@tanstack/match-sorter-utils";
import {
useReactTable,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
useReactTable,
type CellContext,
type ColumnResizeMode,
type ColumnFiltersState,
type FilterFn,
type Column,
type SortingState,
type ColumnDef,
type ColumnFiltersState,
type ColumnResizeMode,
type FilterFn,
type SortDirection,
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
@@ -33,18 +35,15 @@ import { useCopy } from "~/hooks/useCopy";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
import { QueueName } from "../runs/v3/QueueName";
import {
FunnelIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronUpDownIcon,
} from "@heroicons/react/20/solid";
const MAX_STRING_DISPLAY_LENGTH = 64;
const ROW_HEIGHT = 33; // Estimated row height in pixels
@@ -54,7 +53,7 @@ const MIN_COLUMN_WIDTH = 60;
const MAX_COLUMN_WIDTH = 400;
const CHAR_WIDTH_PX = 7.5; // Approximate width of a monospace character at text-xs (12px)
const CELL_PADDING_PX = 40; // px-2 (8px) on each side + buffer for copy button
const HEADER_ICONS_WIDTH_PX = 72; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (16px)
const HEADER_ICONS_WIDTH_PX = 80; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (24px)
const SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
// Type for row data
@@ -68,9 +67,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
if (value === null) return "NULL";
if (value === undefined) return "";
// Handle custom render types
if (column.customRenderType) {
switch (column.customRenderType) {
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
const formatType = column.format ?? column.customRenderType;
if (formatType) {
switch (formatType) {
case "duration":
if (typeof value === "number") {
return formatDurationMilliseconds(value, { style: "short" });
@@ -97,6 +97,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return value;
}
break;
case "bytes":
if (typeof value === "number") {
return formatBytes(value);
}
break;
case "decimalBytes":
if (typeof value === "number") {
return formatDecimalBytes(value);
}
break;
case "percent":
if (typeof value === "number") {
return `${value.toFixed(2)}%`;
}
break;
case "quantity":
if (typeof value === "number") {
return formatQuantity(value);
}
break;
}
}
@@ -113,6 +133,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZone: "UTC",
});
} catch {
return String(value);
@@ -160,10 +181,10 @@ const fuzzyFilter: FilterFn<RowData> = (row, columnId, value, addMeta) => {
cellValue === null
? "NULL"
: cellValue === undefined
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
// Build searchable strings - formatted value (if we have column metadata)
const formattedValue = meta?.outputColumn
@@ -224,6 +245,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
if (value === null) return 4; // "NULL"
if (value === undefined) return 9; // "UNDEFINED"
// Handle format hint types - estimate their rendered width
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes") {
// e.g., "1.50 GiB" or "256.00 MB"
return 12;
}
if (fmt === "percent") {
// e.g., "45.23%"
return 8;
}
if (fmt === "quantity") {
// e.g., "1.50M"
return 8;
}
// Handle custom render types - estimate their rendered width
if (column.customRenderType) {
switch (column.customRenderType) {
@@ -265,6 +301,8 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
return typeof value === "string" ? Math.min(value.length, 20) : 12;
case "queue":
return typeof value === "string" ? Math.min(value.length, 25) : 15;
case "deploymentId":
return typeof value === "string" ? Math.min(value.length, 25) : 20;
}
}
@@ -396,6 +434,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
) {
return true;
}
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
return true;
}
return isNumericType(column.type);
}
@@ -416,7 +458,7 @@ function CellValueWrapper({
return (
<span
className="flex-1"
className="flex flex-1 items-center"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -462,6 +504,7 @@ function CellValue({
</pre>
}
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
disableHoverableContent
/>
);
}
@@ -477,12 +520,45 @@ function CellValue({
return <pre className="text-text-dimmed">UNDEFINED</pre>;
}
// Check format hint for new format types (from prettyFormat())
if (column.format && !column.customRenderType) {
switch (column.format) {
case "bytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatBytes(value)}</span>;
}
break;
case "decimalBytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
}
break;
case "percent":
if (typeof value === "number") {
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
}
break;
case "quantity":
if (typeof value === "number") {
return <span className="tabular-nums">{formatQuantity(value)}</span>;
}
break;
}
}
// 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>;
return (
<SimpleTooltip
content="Jump to run"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
/>
);
}
break;
}
@@ -490,19 +566,17 @@ function CellValue({
const status = isTaskRunStatus(value)
? value
: isRunFriendlyStatus(value)
? runStatusFromFriendlyTitle(value)
: undefined;
? runStatusFromFriendlyTitle(value)
: undefined;
if (status) {
if (hovered) {
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
button={<TaskRunStatusCombo status={status} />}
/>
);
}
return <TaskRunStatusCombo status={status} />;
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
hidden={!hovered}
button={<TaskRunStatusCombo status={status} />}
/>
);
}
break;
}
@@ -573,6 +647,19 @@ function CellValue({
}
return <span>{String(value)}</span>;
}
case "deploymentId": {
if (typeof value === "string" && value.startsWith("deployment_")) {
return (
<SimpleTooltip
content="Jump to deployment"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={`/deployments/${value}`}>{value}</TextLink>}
/>
);
}
return <span>{String(value)}</span>;
}
}
}
@@ -581,7 +668,7 @@ function CellValue({
if (isDateTimeType(type)) {
if (typeof value === "string") {
return <DateTimeAccurate date={value} showTooltip={hovered} />;
return <DateTimeAccurate date={value} showTooltip={hovered} timeZone="UTC" />;
}
return <span>{String(value)}</span>;
}
@@ -607,6 +694,7 @@ function CellValue({
{truncateString(arrayString)}
</span>
}
disableHoverableContent
/>
);
}
@@ -642,6 +730,7 @@ function CellValue({
</pre>
}
button={<span>{truncateString(stringValue)}</span>}
disableHoverableContent
/>
);
}
@@ -672,7 +761,9 @@ function EnvironmentCellValue({ value }: { value: string }) {
}
function JSONCellValue({ value }: { value: unknown }) {
const jsonString = JSON.stringify(value);
// If the value is already a string (e.g., from a textColumn optimization),
// use it directly without double-stringifying
const jsonString = typeof value === "string" ? value : JSON.stringify(value);
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
if (isTruncated) {
@@ -686,6 +777,7 @@ function JSONCellValue({ value }: { value: unknown }) {
button={
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
}
disableHoverableContent
/>
);
}
@@ -711,15 +803,16 @@ function CopyableCell({
return (
<div
className={cn(
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
"bg-background-dimmed group-hover/row:bg-charcoal-800",
"relative flex h-full w-full items-center overflow-hidden px-2",
"bg-background-bright group-hover/row:bg-charcoal-750",
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
alignment === "right" && "justify-end"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{children}</span>
<span className="flex items-center truncate">{children}</span>
{isHovered && (
<span
onClick={(e) => {
@@ -779,18 +872,21 @@ function HeaderCellContent({
onSortClick?: (event: React.MouseEvent) => void;
canSort?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const [isCellHovered, setIsCellHovered] = useState(false);
const [isFilterHovered, setIsFilterHovered] = useState(false);
const sortHighlighted = isCellHovered && !isFilterHovered;
return (
<div
className={cn(
"flex w-full items-center gap-1 overflow-hidden bg-background-dimmed py-1.5 pl-2 pr-1",
"flex w-full items-center gap-1 overflow-hidden bg-background-bright py-2 pl-2 pr-3",
"font-mono text-xs font-medium text-text-bright",
alignment === "right" && "justify-end",
canSort && "cursor-pointer select-none"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseEnter={() => setIsCellHovered(true)}
onMouseLeave={() => setIsCellHovered(false)}
onClick={onSortClick}
>
{tooltip ? (
@@ -800,11 +896,14 @@ function HeaderCellContent({
})}
>
<span className="truncate text-left">{children}</span>
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
/>
<span className="flex flex-shrink-0">
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isCellHovered}
disableHoverableContent
/>
</span>
</div>
) : (
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
@@ -812,7 +911,10 @@ function HeaderCellContent({
{/* Sort indicator */}
{canSort && (
<span
className={cn("flex-shrink-0", sortDirection ? "text-text-bright" : "text-text-dimmed")}
className={cn(
"flex-shrink-0 transition-colors",
sortHighlighted ? "text-text-bright" : "text-text-dimmed"
)}
>
{sortDirection === "asc" ? (
<ChevronUpIcon className="size-4" />
@@ -829,10 +931,12 @@ function HeaderCellContent({
e.stopPropagation();
onFilterClick();
}}
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
onMouseEnter={() => setIsFilterHovered(true)}
onMouseLeave={() => setIsFilterHovered(false)}
className="flex-shrink-0 rounded text-text-dimmed transition-colors focus-custom hover:text-text-bright"
title="Toggle column filters"
>
<FunnelIcon className="size-3" />
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
</button>
)}
</div>
@@ -864,7 +968,7 @@ function FilterCell({
}, [shouldFocus, onFocused]);
return (
<div className="flex items-center bg-background-dimmed px-1.5 pb-1" style={{ width }}>
<div className="flex items-center bg-background-bright px-1.5 pb-2" style={{ width }}>
<DebouncedInput
ref={inputRef}
value={columnFilterValue ?? ""}
@@ -884,10 +988,15 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
rows,
columns,
prettyFormatting = true,
sorting: defaultSorting = [],
showHeaderOnEmpty = false,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
prettyFormatting?: boolean;
sorting?: SortingState;
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
showHeaderOnEmpty?: boolean;
}) {
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -897,7 +1006,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Track which column's filter should be focused
const [focusFilterColumn, setFocusFilterColumn] = useState<string | null>(null);
// State for column sorting
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
// Create TanStack Table column definitions from OutputColumnMetadata
// Calculate column widths based on content
@@ -957,6 +1066,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Empty state
if (rows.length === 0) {
if (!showHeaderOnEmpty) {
return <ChartBlankState icon={IconTable} message="No data to display" />;
}
return (
<div
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
@@ -964,7 +1077,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed"
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -985,63 +1098,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
width: header.getSize(),
}}
>
<HeaderCellContent
alignment={meta?.alignment ?? "left"}
tooltip={meta?.outputColumn.description}
onFilterClick={() => {
if (!showFilters) {
setFocusFilterColumn(header.id);
} else {
setColumnFilters([]);
}
setShowFilters(!showFilters);
}}
showFilters={showFilters}
hasActiveFilter={!!header.column.getFilterValue()}
sortDirection={header.column.getIsSorted()}
onSortClick={header.column.getToggleSortingHandler()}
canSort={header.column.getCanSort()}
>
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
{flexRender(header.column.columnDef.header, header.getContext())}
</HeaderCellContent>
{/* Column resizer */}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
"opacity-0 group-hover/header:opacity-100",
"bg-charcoal-600 hover:bg-indigo-500",
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
)}
/>
</th>
);
})}
</tr>
))}
{/* Filter row - shown when filters are toggled */}
{showFilters && (
<tr style={{ display: "flex", width: "100%" }}>
{table.getHeaderGroups()[0]?.headers.map((header) => (
<FilterCell
key={`filter-${header.id}`}
column={header.column}
width={header.getSize()}
shouldFocus={focusFilterColumn === header.id}
onFocused={() => setFocusFilterColumn(null)}
/>
))}
</tr>
)}
</thead>
<tbody style={{ display: "grid" }}>
<tr style={{ display: "flex" }}>
<td>
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
No results
</Paragraph>
<tr style={{ display: "flex", width: "100%" }}>
<td className="w-full px-3 py-6" colSpan={columns.length}>
<div className="flex items-center justify-center gap-1.5">
<AlertCircle className="size-5 text-text-dimmed/50" />
<Paragraph variant="small" className="text-text-dimmed">
This query returned no results
</Paragraph>
</div>
</td>
</tr>
</tbody>
@@ -1058,7 +1132,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -1105,7 +1179,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none",
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
"opacity-0 group-hover/header:opacity-100",
"bg-charcoal-600 hover:bg-indigo-500",
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
@@ -1137,6 +1211,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
@@ -1144,12 +1219,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
<tr
key={row.id}
data-index={virtualRow.index}
className="group/row hover:bg-charcoal-800"
className="group/row hover:bg-charcoal-750"
style={{
display: "flex",
position: "absolute",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => {
@@ -0,0 +1,183 @@
/**
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
*
* HSL is a human-friendly color model:
* h: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (lightness — 0 is black, 50 is pure color, 100 is white)
*/
interface HSLColor {
h: number;
s: number;
l: number;
}
interface ChartColorDef {
name: string;
hsl: HSLColor;
}
// ---------------------------------------------------------------------------
// Palette — 30 distinct colors for chart series, defined in HSL
// ---------------------------------------------------------------------------
const CHART_COLOR_DEFS: ChartColorDef[] = [
// Primary colors (high contrast, spread across hue wheel)
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
// Extended palette
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
// Additional distinct colors (lighter variants)
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
];
// ---------------------------------------------------------------------------
// HSL ↔ Hex conversion
// ---------------------------------------------------------------------------
/** Convert an HSL color (h: 0360, s: 0100, l: 0100) to a hex string */
function hslToHex({ h, s, l }: HSLColor): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = lNorm - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) {
r1 = c;
g1 = x;
b1 = 0;
} else if (hPrime < 2) {
r1 = x;
g1 = c;
b1 = 0;
} else if (hPrime < 3) {
r1 = 0;
g1 = c;
b1 = x;
} else if (hPrime < 4) {
r1 = 0;
g1 = x;
b1 = c;
} else if (hPrime < 5) {
r1 = x;
g1 = 0;
b1 = c;
} else {
r1 = c;
g1 = 0;
b1 = x;
}
const toHex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
}
/** Convert a hex string to HSL (h: 0360, s: 0100, l: 0100) */
function hexToHsl(hex: string): HSLColor {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h: number;
if (max === r) {
h = 60 * (((g - b) / delta + 6) % 6);
} else if (max === g) {
h = 60 * ((b - r) / delta + 2);
} else {
h = 60 * ((r - g) / delta + 4);
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// ---------------------------------------------------------------------------
// Derived hex palette (for consumers that need plain hex strings)
// ---------------------------------------------------------------------------
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
/** Get the hex color for a series by its index (wraps around) */
export function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
// ---------------------------------------------------------------------------
// Hue-sorted palette (rainbow order for color pickers)
// ---------------------------------------------------------------------------
const SATURATION_THRESHOLD = 10;
/**
* Chart colors sorted by perceived hue — the natural rainbow order
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
*
* Very desaturated colors (like grays) are placed at the end since they don't
* have a strong hue.
*/
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
.sort((a, b) => {
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
// Push desaturated colors to the end
if (aIsGray && !bIsGray) return 1;
if (!aIsGray && bIsGray) return -1;
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
// Sort by hue, then by saturation (more vivid first), then by lightness
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
return a.hsl.l - b.hsl.l;
})
.map((def) => hslToHex(def.hsl));
@@ -1,5 +1,5 @@
import { closeBrackets } from "@codemirror/autocomplete";
import { indentWithTab } from "@codemirror/commands";
import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands";
import { bracketMatching } from "@codemirror/language";
import { lintKeymap } from "@codemirror/lint";
import { highlightSelectionMatches } from "@codemirror/search";
@@ -18,6 +18,7 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
const options = [
drawSelection(),
dropCursor(),
history(),
bracketMatching(),
closeBrackets(),
Prec.highest(
@@ -31,7 +32,15 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
},
])
),
keymap.of([indentWithTab, ...lintKeymap]),
// Explicit undo/redo keybindings with high precedence
Prec.high(
keymap.of([
{ key: "Mod-z", run: undo },
{ key: "Mod-Shift-z", run: redo },
{ key: "Mod-y", run: redo },
])
),
keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]),
];
if (showLineNumbers) {
@@ -67,9 +67,10 @@ export function darkTheme(): Extension {
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: selection,
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: selection,
},
".cm-panels": { backgroundColor: darkBackground, color: ivory },
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
@@ -87,8 +88,8 @@ export function darkTheme(): Extension {
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
backgroundColor: "#bad0f847",
outline: "1px solid #515a6b",
backgroundColor: "rgba(18, 19, 23, 0.9)",
outline: "1px solid rgba(81, 90, 107, 0.5)",
},
".cm-gutters": {
@@ -166,14 +167,20 @@ export function darkTheme(): Extension {
backgroundColor: scrollbarBg,
},
},
{ dark: true }
{ dark: true },
);
/// The highlighting style for code in the JSON Hero theme.
const jsonHeroHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: violet },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
tag: [
tags.name,
tags.deleted,
tags.character,
tags.propertyName,
tags.macroName,
],
color: lilac,
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
@@ -123,6 +123,16 @@ function createFunctionCompletions(): Completion[] {
});
}
// Add special TSQL functions not in the ClickHouse function registry
functions.push({
label: "timeBucket",
type: "function",
detail: "auto time bucket (0 args)",
apply: "timeBucket()",
boost: 1.5,
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
});
return functions;
}
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
className,
tooltipSideOffset = 34,
tooltipSide = "right",
disableTooltip = false,
}: {
environment: Environment;
className?: string;
tooltipSideOffset?: number;
tooltipSide?: "top" | "right" | "bottom" | "left";
disableTooltip?: boolean;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
</span>
);
if (isTruncated) {
if (isTruncated && !disableTooltip) {
return (
<SimpleTooltip
asChild
@@ -10,11 +10,14 @@ import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Paragraph } from "../primitives/Paragraph";
import { CheckboxWithLabel } from "../primitives/Checkbox";
import { Spinner } from "../primitives/Spinner";
type ModalProps = {
id: string;
title: string;
hasVercelIntegration: boolean;
isDevelopment: boolean;
};
type ModalContentProps = ModalProps & {
@@ -22,7 +25,12 @@ type ModalContentProps = ModalProps & {
closeModal: () => void;
};
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
export function RegenerateApiKeyModal({
id,
title,
hasVercelIntegration,
isDevelopment,
}: ModalProps) {
const randomWord = generateTwoRandomWords();
const [open, setOpen] = useState(false);
return (
@@ -37,6 +45,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
<RegenerateApiKeyModalContent
id={id}
title={title}
hasVercelIntegration={hasVercelIntegration}
isDevelopment={isDevelopment}
randomWord={randomWord}
closeModal={() => setOpen(false)}
/>
@@ -45,7 +55,14 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
);
}
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
const RegenerateApiKeyModalContent = ({
id,
randomWord,
title,
hasVercelIntegration,
isDevelopment,
closeModal,
}: ModalContentProps) => {
const [confirmationText, setConfirmationText] = useState("");
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
@@ -83,6 +100,15 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
onChange={(e) => setConfirmationText(e.target.value)}
/>
</InputGroup>
{hasVercelIntegration && !isDevelopment && (
<CheckboxWithLabel
name="syncToVercel"
variant="simple/small"
label="Also update TRIGGER_SECRET_KEY in Vercel"
defaultChecked={true}
value="on"
/>
)}
<FormButtons
confirmButton={
<Button
@@ -0,0 +1,180 @@
import { Switch } from "~/components/primitives/Switch";
import { Label } from "~/components/primitives/Label";
import { Hint } from "~/components/primitives/Hint";
import { TextLink } from "~/components/primitives/TextLink";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
type BuildSettingsFieldsProps = {
availableEnvSlugs: EnvSlug[];
pullEnvVarsBeforeBuild: EnvSlug[];
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
discoverEnvVars: EnvSlug[];
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
atomicBuilds: EnvSlug[];
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
envVarsConfigLink?: string;
};
export function BuildSettingsFields({
availableEnvSlugs,
pullEnvVarsBeforeBuild,
onPullEnvVarsChange,
discoverEnvVars,
onDiscoverEnvVarsChange,
atomicBuilds,
onAtomicBuildsChange,
envVarsConfigLink,
}: BuildSettingsFieldsProps) {
return (
<>
{/* Pull env vars before build */}
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Pull env vars before build</Label>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
}
onCheckedChange={(checked) => {
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should pull environment variables from Vercel before each
build.{" "}
{envVarsConfigLink && (
<>
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
</>
)}
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
return (
<div key={slug} className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={pullEnvVarsBeforeBuild.includes(slug)}
onCheckedChange={(checked) => {
onPullEnvVarsChange(
checked
? [...pullEnvVarsBeforeBuild, slug]
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
);
}}
/>
</div>
);
})}
</div>
</div>
{/* Discover new env vars */}
<div>
<div className="mb-2">
<div className="flex items-center justify-between">
<Label>Discover new env vars</Label>
{availableEnvSlugs.length > 1 && (
<Switch
variant="small"
checked={
availableEnvSlugs.length > 0 &&
availableEnvSlugs.every(
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
) &&
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
}
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
: []
);
}}
/>
)}
</div>
<Hint className="pr-6">
Select which environments should automatically discover and create new environment
variables from Vercel during builds.
</Hint>
</div>
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
{availableEnvSlugs.map((slug) => {
const envType = envSlugToType(slug);
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
return (
<div
key={slug}
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
{environmentFullTitle({ type: envType })}
</span>
</div>
<Switch
variant="small"
checked={discoverEnvVars.includes(slug)}
disabled={isPullDisabled}
onCheckedChange={(checked) => {
onDiscoverEnvVarsChange(
checked
? [...discoverEnvVars, slug]
: discoverEnvVars.filter((s) => s !== slug)
);
}}
/>
</div>
);
})}
</div>
</div>
{/* Atomic deployments */}
<div>
<div className="flex items-center justify-between">
<Label>Atomic deployments</Label>
<Switch
variant="small"
checked={atomicBuilds.includes("prod")}
onCheckedChange={(checked) => {
onAtomicBuildsChange(checked ? ["prod"] : []);
}}
/>
</div>
<Hint className="pr-6">
When enabled, production deployments wait for Vercel deployment to complete before
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
Production Domains" option in your Vercel project settings to perform staged
deployments.{" "}
<TextLink href="https://trigger.dev/docs/vercel-integration#atomic-deployments" target="_blank">
Learn more
</TextLink>
.
</Hint>
</div>
</>
);
}
@@ -0,0 +1,22 @@
import { VercelLogo } from "./VercelLogo";
import { LinkButton } from "~/components/primitives/Buttons";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
return (
<SimpleTooltip
button={
<LinkButton
variant="minimal/small"
LeadingIcon={<VercelLogo className="size-3.5" />}
iconSpacing="gap-x-1"
to={vercelDeploymentUrl}
className="pl-1"
>
Vercel
</LinkButton>
}
content="View on Vercel"
/>
);
}
@@ -0,0 +1,12 @@
export function VercelLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 76 65"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
</svg>
);
}
File diff suppressed because it is too large Load Diff
@@ -21,7 +21,7 @@ export function MainBody({ children }: { children: React.ReactNode }) {
/** This container should be placed around the content on a page */
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
return <div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
}
export function PageBody({
+134 -359
View File
@@ -1,43 +1,30 @@
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import {
type MachinePresetName,
formatDurationMilliseconds,
} from "@trigger.dev/core/v3";
import { XMarkIcon } from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import * as Property from "~/components/primitives/PropertyTable";
import { TextLink } from "~/components/primitives/TextLink";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import {
TaskRunStatusCombo,
descriptionForTaskRunStatus,
} from "~/components/runs/v3/TaskRunStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils";
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { RunTag } from "~/components/runs/v3/RunTag";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { TaskRunStatus } from "@trigger.dev/database";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
type RunContextData = {
run: RunContext | null;
};
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { LogLevel } from "./LogLevel";
import { ExitIcon } from "~/assets/icons/ExitIcon";
type LogDetailViewProps = {
logId: string;
// If we have the log entry from the list, we can display it immediately
@@ -46,27 +33,38 @@ type LogDetailViewProps = {
searchTerm?: string;
};
type TabType = "details" | "run";
type LogAttributes = Record<string, unknown> & {
error?: {
message?: string;
};
};
function getDisplayMessage(log: {
message: string;
level: string;
attributes?: LogAttributes;
}): string {
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = log.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
return message;
}
function formatStringJSON(str: string): string {
return str
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
}
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof logDetailLoader>();
const [activeTab, setActiveTab] = useState<TabType>("details");
const [error, setError] = useState<string | null>(null);
// Fetch full log details when logId changes
@@ -75,7 +73,9 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
setError(null);
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/logs/${encodeURIComponent(logId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, logId]);
@@ -93,17 +93,15 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
const runStatus = fetcher.data?.runStatus;
// Handle Escape key to close panel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: log?.runId ?? "" },
{ spanId: log?.spanId ?? "" }
);
if (isLoading && !log) {
return (
@@ -116,11 +114,16 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
if (!log) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2>Log Details</Header2>
<Button variant="minimal/small" onClick={onClose}>
<XMarkIcon className="size-5" />
</Button>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex flex-1 items-center justify-center">
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
@@ -129,122 +132,113 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
);
}
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: log.runId },
{ spanId: log.spanId }
);
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4 py-3">
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium",
getKindColor(log.kind)
)}
>
{getKindLabel(log.kind)}
</span>
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
getLevelColor(log.level)
)}
>
{log.level}
</span>
</div>
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
<XMarkIcon className="size-5" />
</Button>
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
<TabContainer>
<TabButton
isActive={activeTab === "details"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("details")}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={activeTab === "run"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("run")}
shortcut={{ key: "r" }}
>
Run
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
View Full Run
</Button>
</Link>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "details" && (
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
)}
{activeTab === "run" && (
<RunTab log={log} runPath={runPath} />
)}
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
</div>
</div>
);
}
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
const logWithExtras = log as LogEntry & {
function DetailsTab({
log,
runPath,
runStatus,
searchTerm,
}: {
log: LogEntry & {
attributes?: LogAttributes;
};
runPath: string;
runStatus?: TaskRunStatus;
searchTerm?: string;
}) {
let beautifiedAttributes: string | null = null;
if (logWithExtras.attributes) {
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
if (log.attributes) {
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
}
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
// Determine message to show
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
const message = getDisplayMessage(log);
return (
<>
{/* Time */}
<div className="mb-6">
<Header3 className="mb-2">Timestamp</Header3>
<div className="text-sm text-text-dimmed">
<DateTime date={log.startTime} />
</div>
</div>
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="secondary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
View full run
</LinkButton>
</Property.Value>
</Property.Item>
{runStatus && (
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runStatus} />}
content={descriptionForTaskRunStatus(runStatus)}
disableHoverableContent
className="mt-1"
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Level</Property.Label>
<Property.Value>
<LogLevel level={log.level} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Timestamp</Property.Label>
<Property.Value>
<DateTimeAccurate date={log.triggeredTimestamp} />
</Property.Value>
</Property.Item>
</Property.Table>
{/* Message */}
<div className="mb-6">
<div className="mb-6 mt-3">
<PacketDisplay
data={message}
dataType="application/json"
title="Message"
searchTerm={searchTerm}
wrap={true}
/>
</div>
@@ -262,222 +256,3 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
</>
);
}
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<RunContextData>();
// Fetch run details when tab is active
useEffect(() => {
if (!log.runId) return;
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
const isLoading = fetcher.state === "loading";
const runData = fetcher.data?.run;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
);
}
if (!runData) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText
value={runData.taskIdentifier}
copyValue={runData.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
{runData.rootRun && (
<Property.Item>
<Property.Label>Root and parent run</Property.Label>
<Property.Value>
<CopyableText
value={runData.rootRun.taskIdentifier}
copyValue={runData.rootRun.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
)}
{runData.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<CopyableText
value={runData.batch.friendlyId}
copyValue={runData.batch.friendlyId}
asChild
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{runData.version ? (
environment.type === "DEVELOPMENT" ? (
<CopyableText value={runData.version} copyValue={runData.version} asChild />
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
runData.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText value={runData.version} copyValue={runData.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div>Name: {runData.queue}</div>
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : ""}</div>
</Property.Value>
</Property.Item>
{runData.tags && runData.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{runData.tags.map((tag: string) => (
<RunTag
key={tag}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
{runData.machinePreset ? (
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{runData.baseCostInCents > 0
? formatCurrencyAccurate(runData.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 || runData.baseCostInCents > 0
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{runData.usageDurationMs > 0
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
);
}
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
export function LogLevel({ level }: { level: LogEntry["level"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(level)
)}
>
{level}
</span>
);
}
@@ -1,9 +1,8 @@
import * as Ariakit from "@ariakit/react";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type ReactNode, useMemo } from "react";
import { IconListTree } from "@tabler/icons-react";
import { type ReactNode } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
@@ -12,24 +11,21 @@ import {
shortcutFromIndex,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
{ level: "ERROR", label: "Error", color: "text-error" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
{ level: "INFO", label: "Info", color: "text-blue-400" },
{ level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "ERROR", label: "Error", color: "text-error" },
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
{ level: "TRACE", label: "Trace", color: "text-charcoal-500" },
];
function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
if (showDebug) {
return allLogLevels;
}
return allLogLevels.filter((level) => level.level !== "DEBUG");
// In the future we might add other levels or change which are available
function getAvailableLevels(): typeof allLogLevels {
return allLogLevels;
}
function getLevelBadgeColor(level: LogLevel): string {
@@ -38,14 +34,12 @@ function getLevelBadgeColor(level: LogLevel): string {
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
case "TRACE":
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
case "CANCELLED":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
default:
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
}
@@ -53,81 +47,50 @@ function getLevelBadgeColor(level: LogLevel): string {
const shortcut = { key: "l" };
export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
export function LogsLevelFilter() {
const { values } = useSearchParams();
const selectedLevels = values("levels");
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
if (hasLevels) {
return <AppliedLevelFilter showDebug={showDebug} />;
return <AppliedLevelFilter/>;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<LevelDropdown
trigger={
<SelectTrigger
icon={<ExclamationTriangleIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by level"
>
Level
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
showDebug={showDebug}
/>
)}
</FilterMenuProvider>
<LevelDropdown
trigger={
<SelectTrigger
icon={<IconListTree className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by level"
>
Level
</SelectTrigger>
}
/>
);
}
function LevelDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
showDebug = false,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
showDebug?: boolean;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ levels: values, cursor: undefined, direction: undefined });
};
const availableLevels = getAvailableLevels(showDebug);
const filtered = useMemo(() => {
return availableLevels.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue, availableLevels]);
const availableLevels = getAvailableLevels();
return (
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by level..." value={searchValue} />
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
<SelectList>
{filtered.map((item, index) => (
{availableLevels.map((item, index) => (
<SelectItem
key={item.level}
value={item.level}
@@ -149,7 +112,7 @@ function LevelDropdown({
);
}
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
function AppliedLevelFilter() {
const { values, del } = useSearchParams();
const levels = values("levels");
@@ -158,25 +121,18 @@ function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<LevelDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Level"
icon={<ExclamationTriangleIcon className="size-4" />}
value={appliedSummary(levels)}
onRemove={() => del(["levels", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
showDebug={showDebug}
/>
)}
</FilterMenuProvider>
<LevelDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Level"
icon={<IconListTree className="size-4" />}
value={appliedSummary(levels)}
onRemove={() => del(["levels", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
/>
);
}
@@ -14,7 +14,7 @@ import {
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "r" };
const shortcut = { key: "i" };
export function LogsRunIdFilter() {
const { value } = useSearchParams();
@@ -1,58 +1,62 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { cn } from "~/utils/cn";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
export function LogsSearchInput() {
const location = useOptimisticLocation();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
// Get initial search value from URL
const searchParams = new URLSearchParams(location.search);
const initialSearch = searchParams.get("search") ?? "";
const initialSearch = value("search") ?? "";
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
useEffect(() => {
const params = new URLSearchParams(location.search);
const urlSearch = params.get("search") ?? "";
const urlSearch = value("search") ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
}, [value, text, isFocused]);
const handleSubmit = useCallback(() => {
const params = new URLSearchParams(location.search);
if (text.trim()) {
params.set("search", text.trim());
replace({ search: text.trim() });
} else {
params.delete("search");
del("search");
}
// Reset cursor when searching
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [text, location.pathname, location.search, navigate]);
}, [text, replace, del]);
const handleClear = useCallback(() => {
setText("");
const params = new URLSearchParams(location.search);
params.delete("search");
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate]);
const handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", "cursor", "direction"]);
},
[del]
);
return (
<div className="flex items-center gap-1">
<div className="relative h-6 min-w-52">
<motion.div
initial={{ width: "auto" }}
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className="relative h-6 min-w-52"
>
<Input
type="text"
ref={inputRef}
@@ -61,7 +65,7 @@ export function LogsSearchInput() {
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -76,22 +80,21 @@ export function LogsSearchInput() {
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
</button>
</div>
) : undefined
}
/>
</div>
{text.length > 0 && (
<button
type="button"
onClick={handleClear}
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-4" />
</button>
)}
</motion.div>
</div>
);
}
+54 -45
View File
@@ -1,17 +1,20 @@
import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
import { highlightSearchText } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { DateTime } from "../primitives/DateTime";
import { DateTimeAccurate } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { LogLevel } from "./LogLevel";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
Table,
TableBlankRow,
@@ -23,7 +26,7 @@ import {
TableRow,
type TableVariant,
} from "../primitives/Table";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { RunsIcon } from "~/assets/icons/RunsIcon";
type LogsTableProps = {
logs: LogEntry[];
@@ -32,30 +35,29 @@ type LogsTableProps = {
isLoadingMore?: boolean;
hasMore?: boolean;
onLoadMore?: () => void;
onCheckForMore?: () => void;
variant?: TableVariant;
selectedLogId?: string;
onLogSelect?: (logId: string) => void;
};
// Left border color for error highlighting
function getLevelBorderColor(level: LogEntry["level"]): string {
// Inner shadow for level highlighting (better scroll performance than border-l)
function getLevelBoxShadow(level: LogEntry["level"]): string {
switch (level) {
case "ERROR":
return "border-l-error";
return "inset 2px 0 0 0 rgb(239, 68, 68)";
case "WARN":
return "border-l-warning";
return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
return "border-l-blue-500";
case "CANCELLED":
return "border-l-charcoal-600";
case "DEBUG":
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "TRACE":
return "inset 2px 0 0 0 rgb(168, 85, 247)";
case "DEBUG":
default:
return "border-l-transparent hover:border-l-charcoal-800";
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
@@ -63,6 +65,7 @@ export function LogsTable({
isLoadingMore = false,
hasMore = false,
onLoadMore,
onCheckForMore,
selectedLogId,
onLogSelect,
}: LogsTableProps) {
@@ -112,14 +115,20 @@ export function LogsTable({
}, [hasMore, isLoadingMore, onLoadMore]);
return (
<div className="relative h-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Table variant="compact/mono" containerClassName="overflow-visible">
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
<TableHeader className="sticky top-0 z-10">
<TableRow>
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
<TableHeaderCell className="min-w-24 whitespace-nowrap">Level</TableHeaderCell>
<TableHeaderCell
className="min-w-24 whitespace-nowrap"
tooltip={<LogLevelTooltipInfo />}
disableTooltipHoverableContent
>
Level
</TableHeaderCell>
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -143,8 +152,7 @@ export function LogsTable({
<TableRow
key={log.id}
className={cn(
"cursor-pointer border-l-2 transition-colors",
getLevelBorderColor(log.level),
"cursor-pointer transition-colors",
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
)}
isSelected={isSelected}
@@ -153,24 +161,20 @@ export function LogsTable({
className="whitespace-nowrap tabular-nums"
onClick={handleRowClick}
hasAction
style={{
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTime date={log.startTime} />
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
<TruncatedCopyableValue value={log.runId} />
</TableCell>
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
<span className="font-mono text-xs">{log.taskIdentifier}</span>
</TableCell>
<TableCell onClick={handleRowClick} hasAction>
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<LogLevel level={log.level} />
</TableCell>
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
<span className="block truncate font-mono text-xs" title={log.message}>
@@ -180,12 +184,15 @@ export function LogsTable({
<TableCellMenu
className="pl-32"
hiddenButtons={
<PopoverMenuItem
openInNewTab={true}
<LinkButton
to={runPath}
icon={ArrowTopRightOnSquareIcon}
title="View Run"
/>
variant="minimal/small"
TrailingIcon={RunsIcon}
trailingIconClassName="text-text-bright"
className="h-[1.375rem] pl-1.5 pr-2"
>
<span className="text-[0.6875rem] text-text-bright">View run</span>
</LinkButton>
}
/>
</TableRow>
@@ -196,12 +203,18 @@ export function LogsTable({
</Table>
{/* Infinite scroll trigger */}
{hasMore && logs.length > 0 && (
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
{showLoadMoreSpinner && (
<div className="flex items-center gap-2">
<Spinner /> <span className="text-text-dimmed">Loading more</span>
</div>
)}
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
<div className={cn("flex items-center gap-2", !showLoadMoreSpinner && "invisible")}>
<Spinner /> <span className="text-text-dimmed">Loading more</span>
</div>
</div>
)}
{/* Show all logs message with check for more button */}
{!hasMore && logs.length > 0 && (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
</div>
</div>
)}
</div>
@@ -220,11 +233,7 @@ function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?:
No logs match your filters. Try refreshing or modifying your filters.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={handleRefresh}
>
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
Refresh
</Button>
</div>
@@ -0,0 +1,144 @@
import type { TaskTriggerSource } from "@trigger.dev/database";
import type { ReactNode } from "react";
import { useMemo } from "react";
import * as Ariakit from "@ariakit/react";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
const shortcut = { key: "t" };
type TaskOption = {
slug: string;
triggerSource: TaskTriggerSource;
};
interface LogsTaskFilterProps {
possibleTasks: TaskOption[];
}
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedTasks = values("tasks");
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<SelectTrigger
icon={<TaskIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by task"
>
<span className="ml-0.5">Tasks</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Task"
icon={<TaskIcon className="size-4" />}
value={appliedSummary(
selectedTasks.map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleTasks,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleTasks: TaskOption[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ tasks: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleTasks.filter((item) => {
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleTasks]);
return (
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={`${item.triggerSource}-${item.slug}`}
value={item.slug}
icon={
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
{item.slug}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,523 @@
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { ChartBarIcon } from "@heroicons/react/24/solid";
import { type OutputColumnMetadata } from "@internal/tsql";
import { DialogClose } from "@radix-ui/react-dialog";
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
import { assertNever } from "assert-never";
import { Maximize2 } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { z } from "zod";
import { Card } from "~/components/primitives/charts/Card";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { QueryResultsChart } from "../code/QueryResultsChart";
import { TSQLResultsTable } from "../code/TSQLResultsTable";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { BigNumberCard } from "../primitives/charts/BigNumberCard";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
export type ChartType = z.infer<typeof ChartType>;
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
export type SortDirection = z.infer<typeof SortDirection>;
const AggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
]);
export type AggregationType = z.infer<typeof AggregationType>;
const chartConfigOptions = {
chartType: ChartType,
xAxisColumn: z.string().nullable(),
yAxisColumns: z.string().array(),
groupByColumn: z.string().nullable(),
stacked: z.boolean(),
sortByColumn: z.string().nullable(),
sortDirection: SortDirection,
aggregation: AggregationType,
seriesColors: z.record(z.string()).optional(),
};
const ChartConfiguration = z.object({ ...chartConfigOptions });
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
const BigNumberAggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
z.literal("first"),
z.literal("last"),
]);
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
const bigNumberConfigOptions = {
column: z.string(),
aggregation: BigNumberAggregationType,
sortDirection: BigNumberSortDirection.optional(),
abbreviate: z.boolean().default(false),
prefix: z.string().optional(),
suffix: z.string().optional(),
};
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
export const QueryWidgetConfig = z.discriminatedUnion("type", [
z.object({
type: z.literal("table"),
prettyFormatting: z.boolean().default(true),
sorting: z
.array(
z.object({
desc: z.boolean(),
id: z.string(),
})
)
.default([]),
}),
z.object({
type: z.literal("chart"),
...chartConfigOptions,
}),
z.object({
type: z.literal("bignumber"),
...bigNumberConfigOptions,
}),
z.object({
type: z.literal("title"),
}),
]);
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
/** Result data containing rows and column metadata */
export type QueryWidgetData = {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
};
/** Widget configuration with optional result data (used for edit callbacks) */
export type WidgetData = {
title: string;
query: string;
display: QueryWidgetConfig;
/** The current result data from the widget */
resultData?: QueryWidgetData;
};
export type QueryWidgetProps = {
title: ReactNode;
/** String title for rename dialog (optional - if not provided, rename won't be available) */
titleString?: string;
/** The TSQL query string (used for "Copy query" in the menu) */
query?: string;
isLoading?: boolean;
error?: string;
data: QueryWidgetData;
config: QueryWidgetConfig;
/** The effective time range for the query (used to show full x-axis on time-based charts) */
timeRange?: { from: string; to: string };
accessory?: ReactNode;
isResizing?: boolean;
isDraggable?: boolean;
/** Additional className applied to the Card wrapper */
className?: string;
/** Callback when edit is clicked. Receives the current data. */
onEdit?: (data: QueryWidgetData) => void;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
/** Callback when duplicate is clicked. Receives the current data. */
onDuplicate?: (data: QueryWidgetData) => void;
/** When true, show table column headers even when there are no rows */
showTableHeaderOnEmpty?: boolean;
};
export function QueryWidget({
title,
titleString,
query,
accessory,
isLoading,
error,
isResizing,
isDraggable,
className,
onEdit,
onRename,
onDelete,
onDuplicate,
...props
}: QueryWidgetProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(titleString ?? "");
const containerRef = useRef<HTMLDivElement>(null);
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
const hasData = props.data.rows.length > 0;
// "v" to toggle fullscreen on hovered widget
useShortcutKeys({
shortcut: { key: "v" },
action: useCallback(() => {
const isHovered = containerRef.current?.matches(":hover");
if (!isFullscreen && !isHovered) return;
setIsFullscreen((prev) => !prev);
}, [isFullscreen]),
});
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
}, []);
const copyQuery = useCallback(() => {
if (query) {
copyToClipboard(query);
}
}, [query, copyToClipboard]);
const copyJSON = useCallback(() => {
copyToClipboard(rowsToJSON(props.data.rows));
}, [props.data.rows, copyToClipboard]);
const copyCSV = useCallback(() => {
copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
}, [props.data, copyToClipboard]);
return (
<div ref={containerRef} className="group h-full">
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
<Card.Header draggable={isDraggable}>
<div className="flex items-center gap-1.5">{title}</div>
<Card.Accessory>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="!px-1"
/>
</span>
}
content={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger
isOpen={isMenuOpen}
className={cn(
"transition-opacity",
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
/>
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{hasEditActions && (
<>
{onEdit && (
<PopoverMenuItem
icon={IconChartHistogram}
title="Edit chart"
onClick={() => {
onEdit(props.data);
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
)}
{onRename && (
<PopoverMenuItem
icon={PencilSquareIcon}
title="Rename"
onClick={() => {
setRenameValue(titleString ?? "");
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDuplicate && (
<PopoverMenuItem
icon={DocumentDuplicateIcon}
title="Duplicate chart"
onClick={() => {
onDuplicate(props.data);
setIsMenuOpen(false);
}}
className="pr-4"
/>
)}
</>
)}
{query && (
<PopoverMenuItem
icon={ClipboardIcon}
title="Copy query"
onClick={() => {
copyQuery();
setIsMenuOpen(false);
}}
/>
)}
<PopoverMenuItem
icon={IconBraces}
title="Copy JSON"
disabled={!hasData}
onClick={() => {
copyJSON();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
<PopoverMenuItem
icon={IconFileTypeCsv}
title="Copy CSV"
disabled={!hasData}
onClick={() => {
copyCSV();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete chart"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
{accessory}
</Card.Accessory>
</Card.Header>
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
{isResizing ? (
<div className="flex h-full flex-1 items-center justify-center p-3">
<div className="flex flex-col items-center gap-1 text-text-dimmed">
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
<span className="text-base font-medium">Resizing...</span>
</div>
</div>
) : error ? (
<div className="p-3">
<Callout variant="error">{error}</Callout>
</div>
) : (
<QueryWidgetBody
{...props}
title={title}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
isLoading={isLoading ?? false}
/>
)}
</Card.Content>
</Card>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename chart</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Chart title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
type QueryWidgetBodyProps = {
title: ReactNode;
data: QueryWidgetData;
config: QueryWidgetConfig;
timeRange?: { from: string; to: string };
isFullscreen: boolean;
setIsFullscreen: (open: boolean) => void;
isLoading: boolean;
showTableHeaderOnEmpty?: boolean;
};
function QueryWidgetBody({
title,
data,
config,
timeRange,
isFullscreen,
setIsFullscreen,
isLoading,
showTableHeaderOnEmpty,
}: QueryWidgetBodyProps) {
const type = config.type;
// Only show the loading state if we have no data yet (initial load).
// During a reload with existing data, keep showing the current data
// while the loading bar in the header indicates a refresh is in progress.
const hasData = data.rows.length > 0;
const showLoading = isLoading && !hasData;
switch (type) {
case "table": {
return (
<>
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
fullscreen
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
>
<DialogHeader className="px-4">{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 pt-2.5">
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "chart": {
return (
<>
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
onViewAllLegendItems={() => setIsFullscreen(true)}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
fullLegend
legendScrollable
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "bignumber": {
return (
<>
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "title": {
// Title widgets are rendered by TitleWidget, not QueryWidget
return null;
}
default: {
assertNever(type);
}
}
}
@@ -0,0 +1,212 @@
import * as Ariakit from "@ariakit/react";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useMemo } from "react";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { useDebounceEffect } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "q" };
export function QueuesFilter() {
const { values, replace, del } = useSearchParams();
const selectedQueues = values("queues");
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<SelectTrigger
icon={<RectangleStackIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by queue"
>
<span className="ml-1">Queues</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Queues"
icon={<RectangleStackIcon className="size-4" />}
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
onRemove={() => del(["queues"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function QueuesDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
queues: values.length > 0 ? values : undefined,
});
};
const queueValues = values("queues").filter((v) => v !== "");
const selected = queueValues.length > 0 ? queueValues : undefined;
const fetcher = useFetcher<typeof queuesLoader>();
useDebounceEffect(
searchValue,
(s) => {
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/queues?${searchParams.toString()}`
);
},
250
);
const filtered = useMemo(() => {
// Use a Map to deduplicate by value
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
// Add selected items first (for items not yet loaded from fetcher)
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
itemsMap.set(queueName, {
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
itemsMap.set(queueName, {
name: queueName,
type: "custom",
value: queueName,
});
}
}
}
// Add items from fetcher data
if (fetcher.data !== undefined) {
for (const q of fetcher.data.queues) {
const value = q.type === "task" ? `task/${q.name}` : q.name;
itemsMap.set(value, {
name: q.name,
type: q.type,
value,
});
}
}
const items = Array.from(itemsMap.values());
return matchSorter(items, searchValue, {
keys: ["name"],
});
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by queues..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((queue) => (
<SelectItem
key={queue.value}
value={queue.value}
icon={
queue.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
{queue.name}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No queues found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,181 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useNavigate } from "@remix-run/react";
import { IconCheck } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useEnvironment } from "~/hooks/useEnvironment";
import {
useCustomDashboards,
useOrganization,
useWidgetLimitPerDashboard,
} from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Paragraph } from "../primitives/Paragraph";
import type { QueryWidgetConfig } from "./QueryWidget";
export type SaveToDashboardDialogProps = {
title: string;
query: string;
config: QueryWidgetConfig;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
};
export function SaveToDashboardDialog({
title,
query,
config,
isOpen,
onOpenChange,
}: SaveToDashboardDialogProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const customDashboards = useCustomDashboards();
const widgetLimit = useWidgetLimitPerDashboard();
const fetcher = useFetcher<{ success: boolean }>();
const navigate = useNavigate();
// Find the first dashboard that isn't at the widget limit
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
);
// Build the form action URL
const formAction = selectedDashboardId
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
: "";
const isLoading = fetcher.state === "submitting";
// Check if selected dashboard is at widget limit
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
const isSelectedAtLimit = selectedDashboard
? selectedDashboard.widgetCount >= widgetLimit
: false;
// Navigate to the dashboard when the fetcher completes successfully
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
onOpenChange(false);
navigate(
v3CustomDashboardPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug },
{ friendlyId: selectedDashboardId }
)
);
}
}, [fetcher.state, fetcher.data, selectedDashboardId, onOpenChange, navigate, organization.slug, project.slug, environment.slug]);
// Update selection if dashboards change
useEffect(() => {
if (customDashboards.length > 0 && !selectedDashboardId) {
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
}
}, [customDashboards, selectedDashboardId, widgetLimit]);
if (customDashboards.length === 0) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<div className="!mt-1 space-y-4">
<Paragraph variant="small" className="text-text-dimmed">
You don't have any custom dashboards yet. Create one first from the sidebar menu.
</Paragraph>
<FormButtons
className="justify-end"
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<fetcher.Form method="post" action={formAction} className="space-y-4">
<input type="hidden" name="action" value="add" />
<input type="hidden" name="title" value={title} />
<input type="hidden" name="query" value={query} />
<input type="hidden" name="config" value={JSON.stringify(config)} />
<div className="!mt-1 space-y-2">
<Paragraph variant="small" className="text-text-dimmed">
Select a dashboard to add this chart to:
</Paragraph>
<div className="max-h-64 space-y-1 overflow-y-auto">
{customDashboards.map((dashboard) => {
const isAtLimit = dashboard.widgetCount >= widgetLimit;
return (
<button
key={dashboard.friendlyId}
type="button"
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
disabled={isAtLimit}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
isAtLimit
? "cursor-not-allowed opacity-50"
: selectedDashboardId === dashboard.friendlyId
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
)}
>
{selectedDashboardId === dashboard.friendlyId ? (
<IconCheck className="size-4 shrink-0 text-green-500" />
) : (
<span className="size-4 shrink-0" />
)}
<span className="flex-1 truncate">{dashboard.title}</span>
<span
className={cn(
"shrink-0 text-xs",
isAtLimit ? "text-error" : "text-text-dimmed"
)}
>
{dashboard.widgetCount}/{widgetLimit}
</span>
</button>
);
})}
</div>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
>
{isLoading ? "Saving..." : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,64 @@
import * as Ariakit from "@ariakit/react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { QueryScope } from "~/services/queryService.server";
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { IconListLetters } from "@tabler/icons-react";
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
{ value: "organization", label: "Organization" },
] as const;
export function ScopeFilter() {
const { value, replace } = useSearchParams();
const scope = (value("scope") as QueryScope) ?? "environment";
const handleChange = (newScope: string) => {
replace({ scope: newScope === "environment" ? undefined : newScope });
};
return (
<SelectProvider value={scope} setValue={handleChange}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Scope"
icon={<CubeTransparentIcon className="size-4" />}
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.Select>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ScopeItem scope={option.value} />
</SelectItem>
))}
</SelectPopover>
</SelectProvider>
);
}
function ScopeItem({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return `Org: ${organization.title}`;
case "project":
return `Project: ${project.name}`;
case "environment":
return <EnvironmentLabel environment={environment} />;
default:
return scope;
}
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
export type TitleWidgetProps = {
title: string;
isDraggable?: boolean;
isResizing?: boolean;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
};
export function TitleWidget({
title,
isDraggable,
isResizing,
onRename,
onDelete,
}: TitleWidgetProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title);
const hasMenu = onRename || onDelete;
return (
<div className="h-full">
<div
className={cn(
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
)}
>
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
{title}
</span>
{hasMenu && (
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{onRename && (
<PopoverMenuItem
icon={PencilIcon}
title="Rename"
onClick={() => {
setRenameValue(title);
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -8,10 +8,11 @@ import {
personalAccessTokensPath,
rootPath,
} from "~/utils/pathBuilder";
import { AskAI } from "../AskAI";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
export function AccountSideMenu({ user }: { user: User }) {
return (
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
data-action="security"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
@@ -0,0 +1,310 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { motion } from "framer-motion";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useState } from "react";
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
function useCreateDashboard({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const isFreePlan = plan?.v3Subscription?.isPaying === false;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
return {
isOpen,
setIsOpen,
isAtLimit,
canUpgrade: !!canUpgrade,
isFreePlan,
formAction,
limits,
organization,
};
}
export function CreateDashboardButton({
organization,
project,
environment,
isCollapsed,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
if (isCollapsed) return null;
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
>
<PlusIcon className="size-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Create dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
export function CreateDashboardPageButton({
organization,
project,
environment,
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
}) {
const dashboard = useCreateDashboard({ organization, project, environment });
return (
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Create custom dashboard
</Button>
</DialogTrigger>
{dashboard.isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={dashboard.limits}
canUpgrade={dashboard.canUpgrade}
isFreePlan={dashboard.isFreePlan}
organization={dashboard.organization}
/>
) : (
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
)}
</Dialog>
);
}
const PROGRESS_RING_R = 27.5;
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
function CreateDashboardUpgradeDialog({
limits,
canUpgrade,
isFreePlan,
organization,
}: {
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: { slug: string };
}) {
if (isFreePlan) {
return (
<DialogContent>
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
<DialogDescription className="pt-0">
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
track your task metrics.
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
</DialogFooter>
</DialogContent>
);
}
const percentage = Math.min(limits.used / limits.limit, 1);
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
return (
<DialogContent>
<DialogHeader>Dashboard limit reached</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
/>
<motion.circle
className="fill-none"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
strokeLinecap="round"
initial={{
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_SUCCESS,
}}
animate={{
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_ERROR,
}}
transition={{ duration: 1.2, ease: "easeInOut" }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
{limits.limit}
</span>
</div>
<DialogDescription className="pt-0">
{canUpgrade ? (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
Upgrade your plan to create more.
</>
) : (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
To create more, request a limit increase or visit the{" "}
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
details.
</>
)}
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
{canUpgrade ? (
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
);
}
function CreateDashboardDialog({
formAction,
limits,
}: {
formAction: string;
limits: { used: number; limit: number };
}) {
const navigation = useNavigation();
const [title, setTitle] = useState("");
const isLoading = navigation.formAction === formAction;
return (
<DialogContent className="sm:max-w-sm">
<DialogHeader>Create dashboard</DialogHeader>
<Form method="post" action={formAction} className="space-y-4 pt-3">
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Dashboard"
required
/>
</InputGroup>
<Paragraph variant="extra-small" className="text-text-dimmed">
{limits.used}/{limits.limit} dashboards used
</Paragraph>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
{isLoading ? "Creating..." : "Create"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
);
}
@@ -0,0 +1,123 @@
import { IconChartHistogram } from "@tabler/icons-react";
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
import ReactGridLayout from "react-grid-layout";
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { SideMenuItem } from "./SideMenuItem";
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
import { useReorderableList } from "./useReorderableList";
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
isImpersonating: boolean;
};
export function DashboardList({
organization,
project,
environment,
isCollapsed,
user,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
user: SideMenuUser;
}) {
const customDashboards = useCustomDashboards();
const initialOrder =
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
"customDashboards"
];
const {
orderedItems: orderedDashboards,
layout,
containerRef,
gridWidth,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
} = useReorderableList({
organizationId: organization.id,
listId: "customDashboards",
items: customDashboards,
itemKey: (d) => d.friendlyId,
initialOrder,
isImpersonating: user.isImpersonating,
});
return (
<div ref={containerRef}>
{canReorder ? (
<ReactGridLayout
layout={layout}
width={gridWidth}
gridConfig={{
cols: 1,
rowHeight: 32,
margin: [0, 0] as const,
containerPadding: [0, 0] as const,
}}
resizeConfig={{ enabled: false }}
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
onDrag={handleDrag}
onDragStop={handleDragStop}
className="sidebar-reorder-grid"
autoSize
>
{orderedDashboards.map((dashboard, index) => {
const isLast = getIsLast(dashboard.friendlyId, index);
return (
<div key={dashboard.friendlyId}>
<SideMenuItem
name={dashboard.title}
icon={
isCollapsed
? IconChartHistogram
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
action={
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
}
/>
</div>
);
})}
</ReactGridLayout>
) : (
orderedDashboards.map((dashboard, index) => {
const isLast = index === orderedDashboards.length - 1;
return (
<SideMenuItem
key={dashboard.friendlyId}
name={dashboard.title}
icon={
isCollapsed
? LineChartIcon
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
/>
);
})
)}
</div>
);
}
@@ -1,4 +1,5 @@
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
import { ButtonContent } from "../primitives/Buttons";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
PopoverTrigger,
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip } from "../primitives/Tooltip";
import { V4Badge } from "../V4Badge";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { Badge } from "../primitives/Badge";
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
project,
environment,
className,
isCollapsed = false,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
isCollapsed?: boolean;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
<PopoverArrowTrigger
isOpen={isMenuOpen}
overflowHidden
fullWidth
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
>
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
</PopoverArrowTrigger>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
<span
className={cn(
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
</span>
</span>
<span
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
)}
>
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
</span>
</PopoverTrigger>
}
content={environmentFullTitle(environment)}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "bottom"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
@@ -8,9 +8,12 @@ import {
SignalIcon,
StarIcon,
} from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useState } from "react";
import { motion } from "framer-motion";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Feedback } from "../Feedback";
import { Shortcuts } from "../Shortcuts";
import { StepContentContainer } from "../StepContentContainer";
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
import { Icon } from "../primitives/Icon";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { SimpleTooltip } from "../primitives/Tooltip";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { StepNumber } from "../primitives/StepNumber";
import { SideMenuItem } from "./SideMenuItem";
import { Badge } from "../primitives/Badge";
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
export function HelpAndFeedback({
disableShortcut = false,
isCollapsed = false,
}: {
disableShortcut?: boolean;
isCollapsed?: boolean;
}) {
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
const currentPlan = useCurrentPlan();
useShortcutKeys({
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
action: (e) => {
e.preventDefault();
e.stopPropagation();
setHelpMenuOpen(true);
},
});
return (
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
<PopoverSideMenuTrigger
isOpen={isHelpMenuOpen}
shortcut={{ key: "h", enabledOnInputElements: false }}
className="grow pr-2"
disabled={disableShortcut}
>
<div className="flex items-center gap-1.5">
<QuestionMarkCircleIcon className="size-4 text-success" />
Help & Feedback
</div>
</PopoverSideMenuTrigger>
<motion.div
layout="position"
transition={{ duration: 0.2, ease: "easeInOut" }}
className={isCollapsed ? undefined : "flex-1"}
>
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
<SimpleTooltip
button={
<PopoverTrigger
className={cn(
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750 focus-custom",
isCollapsed ? "w-full" : "w-full justify-between"
)}
>
<span className="flex items-center gap-1.5 overflow-hidden">
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
<span
className={cn(
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
)}
>
Help & Feedback
</span>
</span>
<ShortcutKey
className={cn(
"size-4 flex-none transition-all duration-150",
isCollapsed ? "hidden" : ""
)}
shortcut={{ key: "h" }}
variant="medium/bright"
/>
</PopoverTrigger>
}
content={
<span className="flex items-center gap-1">
Help & Feedback
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
</span>
}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8 w-full"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
side={isCollapsed ? "right" : "top"}
sideOffset={isCollapsed ? 8 : 4}
align="start"
>
<Fragment>
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
button={
<Button
variant="small-menu-item"
className="pl-2"
LeadingIcon={EnvelopeIcon}
leadingIconClassName="text-blue-500"
leadingIconClassName="text-blue-500 pr-1"
data-action="contact-us"
fullWidth
textAlignLeft
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
</div>
</Fragment>
</PopoverContent>
</Popover>
</Popover>
</motion.div>
);
}
@@ -6,12 +6,16 @@ import {
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationTeamPath,
organizationVercelIntegrationPath,
rootPath,
v3BillingAlertsPath,
v3BillingPath,
@@ -25,6 +29,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Paragraph } from "../primitives/Paragraph";
import { Badge } from "../primitives/Badge";
import { useHasAdminAccess } from "~/hooks/useUser";
import { AskAI } from "../AskAI";
export type BuildInfo = {
appVersion: string | undefined;
@@ -113,6 +118,25 @@ export function OrganizationSettingsSideMenu({
data-action="settings"
/>
</div>
<div className="flex flex-col">
<div className="mb-1">
<SideMenuHeader title="Integrations" />
</div>
<SideMenuItem
name="Vercel"
icon={VercelLogo}
activeIconColor="text-white"
to={organizationVercelIntegrationPath(organization)}
data-action="integrations"
/>
<SideMenuItem
name="Slack"
icon={SlackIcon}
activeIconColor="text-white"
to={organizationSlackIntegrationPath(organization)}
data-action="integrations"
/>
</div>
<div className="flex flex-col gap-1">
<SideMenuHeader title="App version" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
@@ -131,7 +155,14 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git ref" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitRefName}
<a
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitRefName}
</a>
</Paragraph>
</div>
)}
@@ -139,13 +170,21 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git sha" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitSha.slice(0, 9)}
<a
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitSha.slice(0, 9)}
</a>
</Paragraph>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
<HelpAndFeedback />
<AskAI />
</div>
</div>
);
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,21 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
export function SideMenuHeader({
title,
children,
isCollapsed = false,
collapsedTitle,
}: {
title: string;
children?: React.ReactNode;
isCollapsed?: boolean;
/** When provided, this text stays visible when collapsed and the rest fades out */
collapsedTitle?: string;
}) {
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
const navigation = useNavigation();
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
setHeaderMenuOpen(false);
}, [navigation.location?.pathname]);
// If collapsedTitle is provided and title starts with it, split the title
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
return (
<div className="group flex items-center justify-between pl-1.5">
<h2 className="text-xs">{title}</h2>
<motion.div
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
initial={false}
animate={{
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<h2 className="text-xs whitespace-nowrap">
{visiblePart}
{fadingPart && (
<motion.span
initial={false}
animate={{
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{fadingPart}
</motion.span>
)}
</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
</PopoverContent>
</Popover>
) : null}
</div>
</motion.div>
);
}
@@ -1,8 +1,10 @@
import { type AnchorHTMLAttributes, type ReactNode } from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { type RenderIcon } from "../primitives/Icon";
import { type RenderIcon, Icon } from "../primitives/Icon";
import { SimpleTooltip } from "../primitives/Tooltip";
export function SideMenuItem({
icon,
@@ -14,6 +16,8 @@ export function SideMenuItem({
to,
badge,
target,
isCollapsed = false,
action,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -24,30 +28,92 @@ export function SideMenuItem({
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
action?: ReactNode;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={icon}
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
TrailingIcon={trailingIcon}
trailingIconClassName={trailingIconClassName}
const link = (
<Link
to={to}
target={target}
className={cn(
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750 group-hover/menuitem:bg-charcoal-750",
isActive ? "bg-tertiary" : ""
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
<Icon
icon={icon}
className={cn(
"size-5 shrink-0",
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
)}
/>
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<span className="truncate select-none text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
opacity: 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
{badge}
</motion.div>
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</motion.div>
</Link>
);
if (action) {
return (
<div className="group/menuitem relative h-8 w-full">
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
{!isCollapsed && (
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
{action}
</div>
)}
</div>
</LinkButton>
);
}
return (
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
);
}
@@ -7,6 +7,11 @@ type Props = {
initialCollapsed?: boolean;
onCollapseToggle?: (isCollapsed: boolean) => void;
children: React.ReactNode;
/** When true, hides the section header and shows only children */
isSideMenuCollapsed?: boolean;
itemSpacingClassName?: string;
/** Optional action element (e.g., + button) to render on the right side of the header */
headerAction?: React.ReactNode;
};
/** A collapsible section for the side menu
@@ -17,6 +22,9 @@ export function SideMenuSection({
initialCollapsed = false,
onCollapseToggle,
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -27,22 +35,45 @@ export function SideMenuSection({
}, [isCollapsed, onCollapseToggle]);
return (
<div>
<div
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={handleToggle}
>
<h2 className="text-xs">{title}</h2>
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
>
<ToggleArrowIcon className="size-2" />
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
initial={false}
animate={{
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
/>
</div>
<AnimatePresence initial={false}>
<motion.div
className="w-full"
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
exit="collapsed"
@@ -63,6 +94,7 @@ export function SideMenuSection({
style={{ overflow: "hidden" }}
>
<motion.div
className={`w-full ${itemSpacingClassName}`}
variants={{
expanded: {
translateY: 0,
@@ -0,0 +1,29 @@
import { cn } from "~/utils/cn";
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
export function TreeConnectorBranch({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
export function TreeConnectorEnd({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
@@ -0,0 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,129 @@
import { useFetcher } from "@remix-run/react";
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
import { type Layout, useContainerWidth } from "react-grid-layout";
/**
* Generic hook for managing a reorderable list in the side menu.
*
* Handles order state, sorting, grid layout, drag callbacks, and persistence
* via the `/resources/preferences/sidemenu` resource route.
*
* @param organizationId - Organization ID for scoping the persisted order
* @param listId - Identifier for this list (e.g. "customDashboards")
* @param items - The items to reorder
* @param itemKey - Extract a stable string key from each item
* @param initialOrder - Initial order from stored preferences (if any)
* @param isImpersonating - Skip persistence when impersonating
*/
export function useReorderableList<T>({
organizationId,
listId,
items,
itemKey,
initialOrder,
isImpersonating,
}: {
organizationId: string;
listId: string;
items: T[];
itemKey: (item: T) => string;
initialOrder: string[] | undefined;
isImpersonating: boolean;
}) {
const orderFetcher = useFetcher();
const [order, setOrder] = useState<string[]>(
() => initialOrder ?? items.map(itemKey)
);
// Sync order when organizationId changes (component may not remount)
useEffect(() => {
setOrder(initialOrder ?? items.map(itemKey));
}, [organizationId]);
// Sort items by stored order, new items go to end
const orderedItems = useMemo(() => {
const orderMap = new Map(order.map((id, i) => [id, i]));
return [...items].sort((a, b) => {
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
return aIdx - bIdx;
});
}, [items, order, itemKey]);
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
const layout = useMemo(
() =>
orderedItems.map((item, i) => ({
i: itemKey(item),
x: 0,
y: i,
w: 1,
h: 1,
})),
[orderedItems, itemKey]
);
// Width measurement for ReactGridLayout
const {
width: gridWidth,
containerRef,
mounted: gridMounted,
} = useContainerWidth({ initialWidth: 216 });
const canReorder = orderedItems.length >= 2;
// Track layout during drag for real-time visual updates
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
const handleDrag = useCallback((layout: Layout) => {
setDragLayout(layout);
}, []);
// Handle drag stop - extract new order from layout y-positions
const handleDragStop = useCallback(
(layout: Layout) => {
setDragLayout(null);
const sorted = [...layout].sort((a, b) => a.y - b.y);
const newOrder = sorted.map((item) => item.i);
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
setOrder(newOrder);
// Persist immediately
if (!isImpersonating) {
const formData = new FormData();
formData.append("organizationId", organizationId);
formData.append("listId", listId);
formData.append("itemOrder", JSON.stringify(newOrder));
orderFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
}
},
[order, organizationId, listId, isImpersonating, orderFetcher]
);
// Compute which item is visually last (during drag or at rest)
const getIsLast = useCallback(
(key: string, index: number) => {
if (dragLayout) {
const maxY = Math.max(...dragLayout.map((l) => l.y));
return dragLayout.find((l) => l.i === key)?.y === maxY;
}
return index === orderedItems.length - 1;
},
[dragLayout, orderedItems.length]
);
return {
orderedItems,
layout,
containerRef: containerRef as Ref<HTMLDivElement>,
gridWidth,
gridMounted,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
};
}
@@ -0,0 +1,375 @@
import * as Ariakit from "@ariakit/react";
import {
XMarkIcon,
PlusIcon,
CubeIcon,
MagnifyingGlassIcon,
ChevronDownIcon,
} from "@heroicons/react/20/solid";
import { useCallback, useMemo, useRef, useState } from "react";
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
import { cn } from "~/utils/cn";
import { matchSorter } from "match-sorter";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
const pillColors = [
"bg-green-800/40 border-green-600/50",
"bg-teal-800/40 border-teal-600/50",
"bg-blue-800/40 border-blue-600/50",
"bg-indigo-800/40 border-indigo-600/50",
"bg-violet-800/40 border-violet-600/50",
"bg-purple-800/40 border-purple-600/50",
"bg-fuchsia-800/40 border-fuchsia-600/50",
"bg-pink-800/40 border-pink-600/50",
"bg-rose-800/40 border-rose-600/50",
"bg-orange-800/40 border-orange-600/50",
"bg-amber-800/40 border-amber-600/50",
"bg-yellow-800/40 border-yellow-600/50",
"bg-lime-800/40 border-lime-600/50",
"bg-emerald-800/40 border-emerald-600/50",
"bg-cyan-800/40 border-cyan-600/50",
"bg-sky-800/40 border-sky-600/50",
];
function getPillColor(value: string): string {
let hash = 0;
for (let i = 0; i < value.length; i++) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return pillColors[Math.abs(hash) % pillColors.length];
}
export const TECHNOLOGY_OPTIONS = [
"Airflow",
"Angular",
"Anthropic",
"Astro",
"Auth0",
"AWS",
"AWS SQS",
"Azure",
"BigQuery",
"BullMQ",
"Bun",
"Cassandra",
"Celery",
"ClickHouse",
"Clerk",
"Cloudflare",
"CockroachDB",
"Cohere",
"Convex",
"Databricks",
"Datadog",
"DeepSeek",
"Deno",
"DigitalOcean",
"Django",
"Docker",
"Drizzle",
"DynamoDB",
"Elasticsearch",
"Electron",
"Elevenlabs",
"Expo",
"Express",
"FastAPI",
"Fastify",
"Firebase",
"Flask",
"Fly.io",
"Gatsby",
"GCP",
"Go",
"Google Cloud Tasks",
"Google Gemini",
"GraphQL",
"Groq",
"Heroku",
"Hono",
"htmx",
"Hugging Face",
"Inngest",
"Kafka",
"Kubernetes",
"LangChain",
"Laravel",
"LlamaIndex",
"MariaDB",
"Midjourney",
"Mistral",
"MongoDB",
"Mongoose",
"MySQL",
"Neo4j",
"Neon",
"Nest.js",
"Netlify",
"Next.js",
"Node.js",
"Nuxt",
"Ollama",
"OpenAI",
"Perplexity",
"PHP",
"Pinecone",
"PlanetScale",
"Python",
"PostHog",
"PostgreSQL",
"Prisma",
"Pulumi",
"RabbitMQ",
"Railway",
"React",
"React Native",
"Redis",
"Redshift",
"Remix",
"Render",
"Replicate",
"Resend",
"Ruby on Rails",
"Rust",
"SendGrid",
"Sentry",
"Sidekiq",
"Snowflake",
"Solid.js",
"Spring Boot",
"SQLite",
"Stability AI",
"Stripe",
"Supabase",
"Svelte",
"SvelteKit",
"Tailwind CSS",
"Temporal",
"Terraform",
"Together AI",
"tRPC",
"Turso",
"Twilio",
"TypeORM",
"Upstash",
"Vercel",
"Vercel AI SDK",
"Vite",
"Vue",
"Weaviate",
] as const;
type TechnologyPickerProps = {
value: string[];
onChange: (value: string[]) => void;
customValues: string[];
onCustomValuesChange: (values: string[]) => void;
};
export function TechnologyPicker({
value,
onChange,
customValues,
onCustomValuesChange,
}: TechnologyPickerProps) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [otherInputValue, setOtherInputValue] = useState("");
const [showOtherInput, setShowOtherInput] = useState(false);
const otherInputRef = useRef<HTMLInputElement>(null);
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
const filteredOptions = useMemo(() => {
if (!searchValue) return TECHNOLOGY_OPTIONS;
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
}, [searchValue]);
const toggleOption = useCallback(
(option: string) => {
if (value.includes(option)) {
onChange(value.filter((v) => v !== option));
} else {
onChange([...value, option]);
}
},
[value, onChange]
);
const removeItem = useCallback(
(item: string) => {
if (value.includes(item)) {
onChange(value.filter((v) => v !== item));
} else {
onCustomValuesChange(customValues.filter((v) => v !== item));
}
},
[value, onChange, customValues, onCustomValuesChange]
);
const addCustomValue = useCallback(() => {
const trimmed = otherInputValue.trim();
if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) {
onCustomValuesChange([...customValues, trimmed]);
setOtherInputValue("");
}
}, [otherInputValue, customValues, onCustomValuesChange, value]);
const handleOtherKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addCustomValue();
}
},
[addCustomValue]
);
return (
<div className="flex flex-col gap-2">
{allSelected.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allSelected.map((item) => (
<span
key={item}
className={cn(
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
getPillColor(item)
)}
>
{item}
<button
type="button"
onClick={() => removeItem(item)}
aria-label={`Remove ${item}`}
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
>
<XMarkIcon className="size-3.5" />
</button>
</span>
))}
</div>
)}
<Ariakit.ComboboxProvider
resetValueOnHide
setValue={(val) => {
setSearchValue(val);
}}
>
<Ariakit.SelectProvider
open={open}
setOpen={setOpen}
value={value}
setValue={(v) => {
if (Array.isArray(v)) {
onChange(v);
}
}}
virtualFocus
>
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
<div className="flex grow items-center">
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
<span>Select your technologies</span>
</div>
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
</Ariakit.Select>
<Ariakit.SelectPopover
gutter={5}
unmountOnHide
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,var(--popover-anchor-width))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(400px,var(--popover-available-height))]"
)}
>
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
<Ariakit.Combobox
autoSelect
placeholder="Search technologies…"
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
/>
</div>
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
{filteredOptions.map((option) => (
<Ariakit.ComboboxItem
key={option}
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
onClick={(e) => {
e.preventDefault();
toggleOption(option);
}}
>
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
<CheckboxIndicator checked={value.includes(option)} />
<span className="grow truncate text-text-bright">{option}</span>
</div>
</Ariakit.ComboboxItem>
))}
{filteredOptions.length === 0 && searchValue && (
<div className="px-3 py-2 text-xs text-text-dimmed">
No matches for &ldquo;{searchValue}&rdquo;
</div>
)}
</Ariakit.ComboboxList>
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
{showOtherInput ? (
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
<input
ref={otherInputRef}
type="text"
value={otherInputValue}
onChange={(e) => setOtherInputValue(e.target.value)}
onKeyDown={handleOtherKeyDown}
placeholder="Type and press Enter to add"
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
autoFocus
/>
<ShortcutKey
shortcut={{ key: "Enter" }}
variant="small"
className={cn(
"mr-1.5 transition-opacity duration-150",
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
)}
/>
<button
type="button"
onClick={() => {
setOtherInputValue("");
setShowOtherInput(false);
}}
className="flex items-center text-text-dimmed hover:text-text-bright"
>
<XMarkIcon className="size-4" />
</button>
</div>
) : (
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
onClick={() => {
setShowOtherInput(true);
setTimeout(() => otherInputRef.current?.focus(), 0);
}}
>
<PlusIcon className="size-4 flex-none" />
<span>Other (not listed)</span>
</button>
)}
</div>
</Ariakit.SelectPopover>
</Ariakit.SelectProvider>
</Ariakit.ComboboxProvider>
</div>
);
}
@@ -1,9 +1,64 @@
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) {
/**
* Determines the number of decimal places to display based on the value.
* - For integers or large numbers (>=100), no decimals
* - For numbers >= 10, 1 decimal place
* - For numbers >= 1, 2 decimal places
* - For smaller numbers, up to 4 decimal places
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const absValue = Math.abs(value);
if (absValue >= 100) return 0;
if (absValue >= 10) return 1;
if (absValue >= 1) return 2;
if (absValue >= 0.1) return 3;
return 4;
}
/**
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
* - Rounds to an integer
* - Clamps to the valid 0-20 range for toLocaleString options
*/
function sanitizeDecimals(decimals: number): number {
if (!Number.isFinite(decimals)) {
return 0;
}
return Math.min(20, Math.max(0, Math.round(decimals)));
}
export function AnimatedNumber({
value,
duration = 0.5,
decimalPlaces,
}: {
value: number;
duration?: number;
/** Number of decimal places to display. If not provided, auto-detects based on value. */
decimalPlaces?: number;
}) {
const motionValue = useMotionValue(value);
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
// Determine decimal places - use provided value or auto-detect, then sanitize
const safeDecimals = useMemo(() => {
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
return sanitizeDecimals(rawDecimals);
}, [decimalPlaces, value]);
const display = useTransform(motionValue, (current) => {
if (safeDecimals === 0) {
return Math.round(current).toLocaleString();
}
return current.toLocaleString(undefined, {
minimumFractionDigits: safeDecimals,
maximumFractionDigits: safeDecimals,
});
});
useEffect(() => {
animate(motionValue, value, {
@@ -27,6 +27,7 @@ type AppliedFilterProps = {
onRemove?: () => void;
variant?: Variant;
className?: string;
valueClassName?: string;
};
export function AppliedFilter({
@@ -37,6 +38,7 @@ export function AppliedFilter({
onRemove,
variant = "secondary/small",
className,
valueClassName,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
@@ -48,14 +50,18 @@ export function AppliedFilter({
className
)}
>
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
<div
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
>
<div className="-mt-[0.5px] flex items-center gap-1">
{icon}
{label && <div className="text-text-bright">
<span>{label}</span>:
</div>}
{label && (
<div className="text-text-bright">
<span>{label}</span>:
</div>
)}
</div>
<div className="text-text-dimmed">
<div className={cn("text-text-dimmed", valueClassName)}>
<div>{value}</div>
</div>
</div>
@@ -1,8 +1,10 @@
import {
BoltIcon,
BuildingOffice2Icon,
CodeBracketSquareIcon,
FaceSmileIcon,
FireIcon,
GlobeAltIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/20/solid";
@@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string().url(),
url: z.string(),
lastIconHex: z.string().optional(),
}),
]);
@@ -85,6 +88,7 @@ export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGS
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
"hero:bolt": BoltIcon,
};
export const defaultAvatarColors = [
@@ -179,9 +183,21 @@ function AvatarIcon({
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
if (!avatar.url) {
return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
</span>
);
}
return (
<span className="grid place-items-center" style={styleFromSize(size)}>
<img src={avatar.url} alt="Organization avatar" className="size-6" />
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<img
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
/>
</span>
);
}
@@ -1,4 +1,5 @@
import {
CreditCardIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
@@ -60,10 +61,10 @@ export const variantClasses = {
linkClassName: "transition hover:bg-blue-400/20",
},
pricing: {
className: "border-charcoal-700 bg-charcoal-800",
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
textColor: "text-text-bright",
linkClassName: "transition hover:bg-charcoal-750",
className: "border-indigo-400/20 bg-indigo-800/30",
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-indigo-400" />,
textColor: "text-indigo-300",
linkClassName: "transition hover:bg-indigo-400/20",
},
} as const;
@@ -0,0 +1,24 @@
import { cn } from "~/utils/cn";
export function CheckboxIndicator({ checked }: { checked: boolean }) {
return (
<div
className={cn(
"flex size-4 flex-none items-center justify-center rounded border",
checked ? "border-indigo-500 bg-indigo-600" : "border-charcoal-600 bg-charcoal-700"
)}
>
{checked && (
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 6L5 8.5L9.5 3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
);
}
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
activationMode="manual"
onValueChange={handleValueChange}
{...controlledProps}
{...props}
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)}
>
{children}
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
tabIndex={-1}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
"mt-1 outline-none",
className,
"data-[state=inactive]:hidden"
)}
{...props}
/>
@@ -1,4 +1,5 @@
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { useRouteLoaderData } from "@remix-run/react";
import { Laptop } from "lucide-react";
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
import { CopyButton } from "./CopyButton";
@@ -19,7 +20,7 @@ function getLocalTimeZone(): string {
// For SSR compatibility: returns "UTC" on server, actual timezone on client
function subscribeToTimeZone() {
// No-op - timezone doesn't change
return () => { };
return () => {};
}
function getTimeZoneSnapshot(): string {
@@ -39,6 +40,18 @@ export function useLocalTimeZone(): string {
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
}
/**
* Hook to get the user's preferred timezone.
* Returns the timezone stored in the user's preferences cookie (from root loader),
* falling back to the browser's local timezone if not set.
*/
export function useUserTimeZone(): string {
const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined;
const localTimeZone = useLocalTimeZone();
// Use stored timezone from cookie, or fall back to browser's local timezone
return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone;
}
type DateTimeProps = {
date: Date | string;
timeZone?: string;
@@ -63,7 +76,7 @@ export const DateTime = ({
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
@@ -71,7 +84,7 @@ export const DateTime = ({
<span suppressHydrationWarning>
{formatDateTime(
realDate,
timeZone ?? localTimeZone,
timeZone ?? userTimeZone,
locales,
includeSeconds,
includeTime,
@@ -91,7 +104,7 @@ export const DateTime = ({
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={localTimeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
@@ -167,7 +180,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
@@ -180,10 +193,14 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
// Format with appropriate function
const formattedDateTime = showDatePart
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
@@ -235,14 +252,16 @@ function formatTimeOnly(
const DateTimeAccurateInner = ({
date,
timeZone = "UTC",
timeZone,
previousDate = null,
showTooltip = true,
hideDate = false,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const userTimeZone = useUserTimeZone();
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
const displayTimeZone = timeZone ?? userTimeZone;
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
@@ -253,29 +272,37 @@ const DateTimeAccurateInner = ({
// Smart formatting based on whether date changed
const formattedDateTime = useMemo(() => {
return hideDate
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
}, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]);
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={localTimeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
);
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
@@ -311,9 +338,13 @@ function formatDateTimeAccurate(
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
@@ -323,16 +354,20 @@ function formatDateTimeAccurate(
hour12,
}).format(date);
return formattedDateTime;
return `${datePart} ${timePart}`;
}
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
@@ -7,7 +7,7 @@ export function FormButtons({
className,
}: {
cancelButton?: React.ReactNode;
confirmButton: React.ReactNode;
confirmButton?: React.ReactNode;
defaultAction?: { name: string; value: string; disabled?: boolean };
className?: string;
}) {
@@ -29,7 +29,7 @@ export function FormButtons({
aria-hidden="true"
/>
)}
{cancelButton ? cancelButton : <div />} {confirmButton}
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
</div>
);
}
@@ -1,13 +1,15 @@
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
import { useEffect } from "react";
import { cn } from "~/utils/cn";
type LoadingBarDividerProps = {
isLoading: boolean;
className?: string;
};
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
return (
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
<AnimationDivider isLoading={isLoading} />
</div>
);
@@ -0,0 +1,168 @@
import { useRef, useState, useLayoutEffect, useCallback } from "react";
import { cn } from "~/utils/cn";
import { SimpleTooltip } from "./Tooltip";
type MiddleTruncateProps = {
text: string;
className?: string;
};
/**
* A component that truncates text in the middle, showing the beginning and end.
* Shows the full text in a tooltip on hover when truncated.
*
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
*/
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [displayText, setDisplayText] = useState(text);
const [isTruncated, setIsTruncated] = useState(false);
const calculateTruncation = useCallback(() => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const parent = container.parentElement;
if (!parent) return;
// Get the available width from the parent container
const parentStyle = getComputedStyle(parent);
const availableWidth =
parent.clientWidth -
parseFloat(parentStyle.paddingLeft) -
parseFloat(parentStyle.paddingRight);
// Measure full text width
measure.textContent = text;
const fullTextWidth = measure.offsetWidth;
// If text fits, no truncation needed
if (fullTextWidth <= availableWidth) {
setDisplayText(text);
setIsTruncated(false);
return;
}
// Text needs truncation - find optimal split
const ellipsis = "…";
measure.textContent = ellipsis;
const ellipsisWidth = measure.offsetWidth;
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
if (targetWidth <= 0) {
setDisplayText(ellipsis);
setIsTruncated(true);
return;
}
// Incrementally find the optimal character counts
let startChars = 0;
let endChars = 0;
// Alternate adding characters from start and end
while (startChars + endChars < text.length) {
// Try adding to start
const testStart = text.slice(0, startChars + 1);
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
measure.textContent = testStart + ellipsis + testEnd;
if (measure.offsetWidth > targetWidth) break;
startChars++;
if (startChars + endChars >= text.length) break;
// Try adding to end
const newTestEnd = text.slice(-(endChars + 1));
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
if (measure.offsetWidth > targetWidth) break;
endChars++;
}
// Ensure minimum characters on each side for readability
const minChars = 4;
const prevStartChars = startChars;
const prevEndChars = endChars;
if (startChars < minChars && text.length > minChars * 2 + 1) {
startChars = minChars;
}
if (endChars < minChars && text.length > minChars * 2 + 1) {
endChars = minChars;
}
// Re-measure after enforcing minChars to prevent overflow
if (startChars !== prevStartChars || endChars !== prevEndChars) {
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
if (measure.offsetWidth > targetWidth) {
// Revert to previous values if minChars enforcement causes overflow
startChars = prevStartChars;
endChars = prevEndChars;
}
}
// If combined chars would exceed text length, show full text
if (startChars + endChars >= text.length) {
setDisplayText(text);
setIsTruncated(false);
return;
}
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
setDisplayText(result);
setIsTruncated(true);
}, [text]);
useLayoutEffect(() => {
calculateTruncation();
// Recalculate on resize (guard for jsdom/older browsers)
if (typeof ResizeObserver === "undefined") {
return;
}
const resizeObserver = new ResizeObserver(() => {
calculateTruncation();
});
const container = containerRef.current;
if (container?.parentElement) {
resizeObserver.observe(container.parentElement);
}
return () => {
resizeObserver.disconnect();
};
}, [calculateTruncation]);
const content = (
<span
ref={containerRef}
className={cn("block", isTruncated && "min-w-[360px]", className)}
>
{/* Hidden span for measuring text width */}
<span
ref={measureRef}
className="invisible absolute whitespace-nowrap"
aria-hidden="true"
/>
{displayText}
</span>
);
if (isTruncated) {
return (
<SimpleTooltip
button={content}
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
side="top"
asChild
/>
);
}
return content;
}
@@ -154,10 +154,12 @@ function PopoverSideMenuTrigger({
children,
className,
shortcut,
hideShortcutKey = false,
...props
}: {
isOpen?: boolean;
shortcut?: useShortcutKeys.ShortcutDefinition;
hideShortcutKey?: boolean;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys.useShortcutKeys({
@@ -176,14 +178,14 @@ function PopoverSideMenuTrigger({
{...props}
ref={ref}
className={cn(
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
shortcut ? "justify-between" : "",
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
className
)}
>
{children}
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
{shortcut && !hideShortcutKey && (
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
)}
</PopoverTrigger>
);
@@ -241,20 +243,41 @@ function PopoverArrowTrigger({
);
}
const popoverVerticalEllipseVariants = {
minimal: {
trigger:
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
trigger:
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
icon: "size-4",
},
} as const;
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
function PopoverVerticalEllipseTrigger({
isOpen,
variant = "minimal",
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
}: {
isOpen?: boolean;
variant?: PopoverVerticalEllipseVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const styles = popoverVerticalEllipseVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
"group flex items-center justify-center transition focus-custom",
styles.trigger,
className
)}
>
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
</PopoverTrigger>
);
}
@@ -26,19 +26,40 @@ const ResizableHandle = ({
}) => (
<PanelResizer
className={cn(
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
// Base styles
"group relative flex items-center justify-center focus-custom",
// Horizontal orientation (default)
"w-0.75 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2",
// Vertical orientation
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
className
)}
size="3px"
{...props}
>
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
{/* Horizontal orientation line indicator */}
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
{/* Vertical orientation line indicator */}
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
{withHandle && (
<div className="z-10 flex h-5 w-3 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
))}
</div>
<>
{/* Horizontal orientation dots (vertical arrangement) */}
<div className="z-10 flex h-5 w-0.75 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:hidden">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
))}
</div>
{/* Vertical orientation dots (horizontal arrangement) */}
<div className="z-10 hidden h-0.75 w-5 flex-row items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:flex">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-charcoal-600" />
))}
</div>
</>
)}
</PanelResizer>
);
@@ -1,5 +1,6 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const sizes = {
@@ -63,7 +64,7 @@ const variants = {
type VariantType = keyof typeof variants;
type Options = {
label: string;
label: ReactNode;
value: string;
};
@@ -338,9 +338,9 @@ export function SelectTrigger({
/>
}
>
<div className="flex grow items-center gap-0.5">
{icon && <div className="-ml-1 flex-none">{icon}</div>}
<div className="truncate">{content}</div>
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
{icon && <div className="flex-none">{icon}</div>}
<div className="min-w-0 truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
@@ -443,21 +443,33 @@ export function SelectList(props: SelectListProps) {
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
checkPosition?: "left" | "right";
shortcut?: ShortcutDefinition;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
import { CheckboxIndicator } from "./CheckboxIndicator";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
checkPosition = "right",
shortcut,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
const ref = React.useRef<HTMLDivElement>(null);
const select = Ariakit.useSelectContext();
const selectValue = select?.useState("value");
const isChecked = React.useMemo(() => {
if (!props.value || selectValue == null) return false;
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
return selectValue === props.value;
}, [selectValue, props.value]);
useShortcutKeys({
shortcut: shortcut,
@@ -484,10 +496,16 @@ export function SelectItem({
)}
ref={ref}
>
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
<div
className={cn(
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
checkPosition === "left" ? "gap-2" : "gap-1"
)}
>
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{checkPosition === "right" && checkIcon}
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const small =
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
const medium =
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
);
}
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
function modifierString(
modifier: Modifier,
isMac: boolean,
variant: "small" | "medium" | "medium/bright"
variant: ShortcutKeyVariant
): string | JSX.Element {
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
switch (modifier) {
case "alt":
@@ -64,18 +64,30 @@ type TableProps = {
className?: string;
children: ReactNode;
fullWidth?: boolean;
showTopBorder?: boolean;
};
// Add TableContext
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
(
{
className,
containerClassName,
children,
fullWidth,
variant = "dimmed",
showTopBorder = true,
},
ref
) => {
return (
<TableContext.Provider value={{ variant }}>
<div
className={cn(
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
"overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
showTopBorder && "border-t",
containerClassName,
fullWidth && "w-full"
)}
@@ -164,10 +176,22 @@ type TableCellBasicProps = {
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
disableTooltipHoverableContent?: boolean;
};
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
(
{
className,
alignment = "left",
children,
colSpan,
hiddenLabel = false,
tooltip,
disableTooltipHoverableContent = false,
},
ref
) => {
const { variant } = useContext(TableContext);
let alignmentClassName = "text-left";
switch (alignment) {
@@ -210,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
disableHoverableContent={disableTooltipHoverableContent}
/>
</div>
) : (
@@ -230,6 +255,7 @@ type TableCellProps = TableCellBasicProps & {
isSelected?: boolean;
isTabbableCell?: boolean;
children?: ReactNode;
style?: React.CSSProperties;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
@@ -246,6 +272,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
isSticky = false,
isSelected,
isTabbableCell = false,
style,
},
ref
) => {
@@ -291,6 +318,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
className
)}
colSpan={colSpan}
style={style}
>
{to ? (
<Link
@@ -69,21 +69,26 @@ export function ToastUI({
width: toastWidth,
}}
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
<div
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
>
{variant === "success" ? (
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
) : (
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
)}
<div className="flex flex-col">
{title && <Header2 className="pt-0">{title}</Header2>}
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
<Paragraph
variant={title ? "small/dimmed" : "small/bright"}
className={title ? "pb-1 pt-0.5" : ""}
>
{message}
</Paragraph>
<Action action={action} toastId={t} className="my-2" />
</div>
<button
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
className={cn("-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright", title && "-mt-1")}
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="size-4" />
@@ -87,7 +87,7 @@ function SimpleTooltip({
<TooltipTrigger
type="button"
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
>
@@ -1,46 +0,0 @@
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { Spinner } from "../Spinner";
interface BigNumberProps {
animate?: boolean;
loading?: boolean;
value?: number;
valueClassName?: string;
defaultValue?: number;
suffix?: string;
suffixClassName?: string;
}
export function BigNumber({
value,
defaultValue,
valueClassName,
suffix,
suffixClassName,
animate = false,
loading = false,
}: BigNumberProps) {
const v = value ?? defaultValue;
return (
<div
className={cn(
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
valueClassName
)}
>
{loading ? (
<div className="grid h-full place-items-center">
<Spinner className="size-6" />
</div>
) : v !== undefined ? (
<div className="flex items-baseline gap-1">
{animate ? <AnimatedNumber value={v} /> : v}
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
</div>
) : (
""
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More