Compare commits

...

218 Commits

Author SHA1 Message Date
Claude 432c3601bc feat: add reverse replay tracking with "Replayed as" section in Details tab
- Add replayed_from_friendly_id column to ClickHouse task_runs_v2 table
- Create migration 012_add_task_runs_v2_replayed_from.sql
- Update replication service to include the new field
- Add getRunReplays query to ClickHouse client (optimized with org/project/env filter)
- Update SpanPresenter to query replays from ClickHouse
- Add "Replayed as" section in run Details tab showing linked replay runs with status

The ClickHouse query filters by organization_id, project_id, and environment_id
in the correct order to match the primary key for optimal query performance.

This enables users to see which runs have been replayed from the original run,
addressing the feedback about tracking replay status of failed runs.

Slack thread: https://triggerdotdev.slack.com/archives/C045W9WM3E1/p1767609682537389
2026-01-05 11:38:52 +00:00
Eric Allam edf5b142fc fix(fair-queue): Prevent unbounded memory growth from metrics cardinality explosion (#2819) 2025-12-25 08:51:40 +00:00
Eric Allam 4c3dfac223 fix(fair-queue): prevent unbounded cooloff states growth (#2818) 2025-12-24 16:33:38 +00:00
nicktrn 7ccbbdb368 docs: tidy up sync env vars note blocks (#2817) 2025-12-24 13:02:26 +00:00
Eric Allam 71279a7b12 fix(fair-queue): prevent unbounded memory growth by cleaning up queue descriptor and cooloff state cache (#2816) 2025-12-24 10:40:32 +00:00
Eric Allam 2eba36c086 chore(redis-worker): add otel spans to fair queue processing pipeline (#2815) 2025-12-24 00:37:24 +00:00
Eric Allam 29827e96f2 fix(batch): add batch queue back into master queue after visibility timeout (#2814) 2025-12-24 00:25:46 +00:00
Dan d416f340ad Added example projects link (#2812)
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]_

💯
2025-12-23 18:37:19 +00:00
nicktrn 7a54a843e2 fix(cli): update command should preserve existing package.json order (#2810)
This fixes a regression introduced in #2778 - stable sort is required
for deterministic builds, but we can safely preserve order for the user
package.json during package updates
2025-12-23 12:52:06 +00:00
nicktrn 52e9baede5 fix(webapp): don't rate limit deployment finalization (#2809)
turns out we also had v2 and v3 routes
2025-12-23 09:22:00 +00:00
Eric Allam deb80890fe chore(otel): add spans to the batch queue processing pipeline (#2808) 2025-12-23 08:40:33 +00:00
Eric Allam d82089686c fix(batch): extract the queue name out of an already nested queue option (#2807) 2025-12-22 21:40:14 +00:00
Oskar Otwinowski acc10e847c chore(docs): add more cost saving tips (#2806)
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-12-22 15:36:54 +00:00
Eric Allam f1a83cffc4 chore(docs): debounce options (#2797) 2025-12-22 15:35:51 +00:00
Eric Allam 61fee91830 chore(docs): upgrade for new batch trigger limits and functionality (#2787) 2025-12-22 15:35:43 +00:00
github-actions[bot] caa40ce925 chore: release v4.3.1 (#2788)
🚀 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 4s
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.3.1

### Patch Changes

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

## trigger.dev@4.3.1

### Patch Changes

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

## @trigger.dev/core@4.3.1

### Patch Changes

- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))

## @trigger.dev/python@4.3.1

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.3.1

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.3.1

### Patch Changes

- feat: Support for new batch trigger system
([#2779](https://github.com/triggerdotdev/trigger.dev/pull/2779))
- feat(sdk): Support debouncing runs when triggering with new debounce
options
([#2794](https://github.com/triggerdotdev/trigger.dev/pull/2794))
- Added support for idempotency reset
([#2777](https://github.com/triggerdotdev/trigger.dev/pull/2777))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.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>
2025-12-22 15:29:18 +00:00
Dan ba9b0e17c1 Added Claude Agent SDK guide and examples (#2803) 2025-12-19 17:10:32 +00:00
Oskar Otwinowski 469808cf09 fix(webapp): Make spans fluid again while task is executing (#2801)
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

Tested on executing tasks and on moving between tasks

---

## Changelog

Small UI fix for spans on the task runs page

---

## Screenshots



https://github.com/user-attachments/assets/72095b03-c74f-4472-afde-9c63e6e7c224



💯
2025-12-19 15:45:54 +01:00
Mihai Popescu 7574c69c2d feat(webapp): Add support for resetting idempotency keys (#2777)
Add support for resetting idempotency keys both from ui and sdk

##  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
- Created a new run with a idempotency idempotencyKey.
- Started a new run with the same task and got redirected to the first
run.
- Deleted the key from the UI on the run details
- Started a new run with the same task  and it created a new one
- Did the above steps using the SDK


---

## Changelog

- Add new action route for resetting idempotency keys via UI
- Add reset button in Idempotency section of run detail view
- Added API and SDK for resetting imdepotency
- Updated docs page for this feature


---

## Screenshots

_[Screenshots]_
<img width="438" height="363" alt="Screenshot 2025-12-11 at 11 56 37"
src="https://github.com/user-attachments/assets/30b8ef5e-8aac-4d04-b57a-9bf30d085dcb"
/>
2025-12-19 11:55:04 +00:00
Eric Allam 06cbe6e3ca chore(docs): upgrade sentry guide to use new global init and global hooks (#2799) 2025-12-19 00:00:02 +00:00
Eric Allam 3875bb292a feat(engine): run debounce system (#2794)
Adds support for **debounced task runs** - when triggering a task with a
debounce key, subsequent triggers with the same key will reschedule the
existing delayed run instead of creating new runs. This continues until
no new triggers occur within the delay window.

## Usage

```typescript
await myTask.trigger({ userId: "123" }, {
  debounce: {
    key: "user-123-update",
    delay: "5s",
    mode: "leading", // default
  }
});
```

- **key**: Scoped to the task identifier
- **delay**: How long to wait before executing (supports duration
strings like `"5s"`, `"1m"`)
- **mode**: Either `"leading"` or `"trailing"`. Leading debounce will
use the payload and options from the first run created with the debounce
key. Trailing will use payload and options from the last run.

### "trailing" mode overrides

When using `mode: "trailing"` with debounce, the following options are
updated from the **last** trigger:

- **`payload`** - The task input data
- **`metadata`** - Run metadata
- **`tags`** - Run tags (replaces existing tags)
- **`maxAttempts`** - Maximum retry attempts
- **`maxDuration`** - Maximum compute time
- **`machine`**  - Machine preset (cpu/memory)

## Behavior

- **First run wins**: The first trigger creates the run, subsequent
triggers push its execution time later
- **Idempotency keys take precedence**: If both are specified,
idempotency is checked first
- **Max duration**: Configurable via `DEBOUNCE_MAX_DURATION_MS` env var
(default: 10 minutes)

Works with `triggerAndWait` - parent runs correctly block on the
debounced run.
2025-12-18 16:04:43 +00:00
Saadi Myftija ff80742ab7 fix(ci): use workflow_dispatch instead of repository_dispatch for publish.yml (#2791)
Mixed up the trigger in #2790
2025-12-16 16:53:22 +01:00
Saadi Myftija 11366e658c chore(ci): add manual trigger to the image publishing workflow (#2790)
Useful to retry failures manually.
2025-12-16 16:34:57 +01:00
Eric Allam e751f8832e fix(app): incorrectly duplicated env vars for new batch trigger system (#2789) 2025-12-16 15:27:13 +00:00
Eric Allam a999d9ea3f feat(engine): Batch trigger reloaded (#2779)
New batch trigger system with larger payloads, streaming ingestion,
larger batch sizes, and a fair processing system.

This PR introduces a new `FairQueue` abstraction inspired by our own
`RunQueue` that enables multi-tenant fair queueing with concurrency
limits. The new `BatchQueue` is built on top of the `FairQueue`, and
handles processing Batch triggers in a fair manner with per-environment
concurrency limits defined per-org. Additionally, there is a global
concurrency limit to prevent the BatchQueue system from creating too
many runs too quickly, which can cause downstream issues.

For this new BatchQueue system we have a completely new batch trigger
creation and ingestion system. Previously this was a single endpoint
with a single JSON body that defined details about the batch as well as
all the items in the batch.

We're introducing a two-phase batch trigger ingestion system. In the
first phase, the BatchTaskRun record is created (and possibly rate
limited). The second phase is another endpoint that accepts an NDJSON
body with each line being a single item/run with payload and options.

At ingestion time all items are added to a queue, in order, and then
processed by the BatchQueue system.

## New batch trigger rate limits

This PR implements a new batch trigger specific rate limit, configured
on the `Organization.batchRateLimitConfig` column, and defaults using
these environment variables:

- `BATCH_RATE_LIMIT_REFILL_RATE` defaults to 10
- `BATCH_RATE_LIMIT_REFILL_INTERVAL` the duration interval, defaults to
`"10s"`
- `BATCH_RATE_LIMIT_MAX` defaults to 1200

This rate limiter is scoped to the environment ID and controls how many
runs can be submitted via batch triggers per interval. The SDK handles
the retrying side.

## Batch queue concurrency limits

The new column `Organization.batchQueueConcurrencyConfig` now defines an
org specific `processingConcurrency` value, with a backup of the env var
`BATCH_CONCURRENCY_LIMIT_DEFAULT` which defaults to 10. This controls
how many batch queue items are processed concurrently per environment.

There is also a global rate limit for the batch queue set via the
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` which defaults to being disabled. If
set, the entire batch queue system won't process more than
`BATCH_QUEUE_GLOBAL_RATE_LIMIT` items per second. This allows
controlling the maximum number of runs created per second via batch
triggers.

## Batch trigger settings

- `STREAMING_BATCH_MAX_ITEMS` controls the maximum number of items in a
single batch
- `STREAMING_BATCH_ITEM_MAXIMUM_SIZE` controls the maximum size of each
item in a batch
- `BATCH_CONCURRENCY_DEFAULT_CONCURRENCY` controls the default
environment concurrency
- `BATCH_QUEUE_DRR_QUANTUM` how many credits each environment gets each
round for the DRR scheduler
- `BATCH_QUEUE_MAX_DEFICIT` the maximum deficit for the DRR scheduler
- `BATCH_QUEUE_CONSUMER_COUNT` how many queue consumers to run
- `BATCH_QUEUE_CONSUMER_INTERVAL_MS` how frequently they poll for items
in the queue

### Configuration Recommendations by Use Case

**High-throughput priority (fairness acceptable at 0.98+):**

```env
BATCH_QUEUE_DRR_QUANTUM=25
BATCH_QUEUE_MAX_DEFICIT=100
BATCH_QUEUE_CONSUMER_COUNT=10
BATCH_QUEUE_CONSUMER_INTERVAL_MS=50
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=25
```

**Strict fairness priority (throughput can be lower):**

```env
BATCH_QUEUE_DRR_QUANTUM=5
BATCH_QUEUE_MAX_DEFICIT=25
BATCH_QUEUE_CONSUMER_COUNT=3
BATCH_QUEUE_CONSUMER_INTERVAL_MS=100
BATCH_CONCURRENCY_DEFAULT_CONCURRENCY=5
```
2025-12-16 14:32:49 +00:00
Oskar Otwinowski 28a66ac021 fix(ui): respect rootOnlyDefault, disable adjacent run navigation in inspector views (#2781)
🚀 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 0s
## Changelog

- Add disableAdjacentRows prop to TaskRunsTable component to control
table state encoding
- Pass rootOnlyDefault prop from loader to TaskRunsTable for proper
state management
- Disable adjacent run navigation in schedule, waitpoint, and other
inspector views
- Preserve adjacent run navigation on main runs list page with rootOnly
filter support
2025-12-12 12:16:29 +00:00
github-actions[bot] 7d34817473 chore: release v4.3.0 (#2770)
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@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))
- feat(cli): enable zstd compression for deployment images
([#2773](https://github.com/triggerdotdev/trigger.dev/pull/2773))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))
- fix(dev): stop max listeners exceeded warning messages when running
more than 10 runs concurrently
([#2771](https://github.com/triggerdotdev/trigger.dev/pull/2771))
- Upgrade @modelcontextprotocol/sdk to 1.24.3
([#2768](https://github.com/triggerdotdev/trigger.dev/pull/2768))
-   Updated dependencies:
    -   `@trigger.dev/core@4.3.0`
    -   `@trigger.dev/build@4.3.0`
    -   `@trigger.dev/schema-to-json@4.3.0`

## @trigger.dev/core@4.3.0

### Minor Changes

- feat(cli): deterministic image builds for deployments
([#2778](https://github.com/triggerdotdev/trigger.dev/pull/2778))

### Patch Changes

- The new `triggeredVia` field is now populated in deployments via the
CLI. ([#2767](https://github.com/triggerdotdev/trigger.dev/pull/2767))

## @trigger.dev/build@4.3.0

### Patch Changes

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

## @trigger.dev/python@4.3.0

### Patch Changes

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

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

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/rsc@4.3.0

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.3.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.3.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>
2025-12-12 13:11:56 +01:00
Saadi Myftija 6d6ed471d1 feat(cli): deterministic image builds for deployments (#2778)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
This PR makes our image builds deterministic and reproducible by
ensuring that identical source code always produces the same image
layers and image digest. This means that deployments where nothing has
changed will no longer invalidate the image cache in our worker cluster
nodes, thus avoid making the cold starts for runs worse.

**Context**
New deployments currently increase the cold start times for runs, as
they generate a new image which needs to be pulled in the worker cluster
where runs are executed. It happens also when the source code for the
deployment has not changed due to non-deterministic steps in our build
system. This addresses the latter issue by making builds reproducible.

**Main changes**
- Avoided baking `TRIGGER_DEPLOYMENT_ID` and
`TRIGGER_DEPLOYMENT_VERSION` in the image, we now pass these via the
supervisor instead.
- Used `json-stable-stringify` for consistent key ordering in the files
we generate for the build, e.g., `package.json`, `build.json`,
`index.json`.
- Removed `metafile.json` from the image contents as it is not actually
used in the container. This is only relevant for the `analyze` command.
- Added `SOURCE_DATE_EPOCH=0` and `rewrite-timestamp=true` to Docker
builds to normalize file timestamps.
- Removed some `timings` and `outputHashes` from build outputs and
manifests.

The builds are now reproducible for both native build server and Depot
paths. This should also lead to better image layer cache reuse in
general.
2025-12-12 09:48:04 +01:00
Oskar Otwinowski 7f7f993587 feat(webapp): improve adjacent runs navigation and timeline performance (#2776)
- Add replace prop to LinkButton to use history replacement for adjacent
run navigation
- Preserve span and tab params when navigating between adjacent runs
- Disable animations for completed spans in timeline to improve
performance
- Include spanId in runs list navigation for better context preservation
- Direct link to task test page when filtering by single task with no
runs
- Fix minor styling issue with run friendlyId display padding
2025-12-11 17:26:15 +00:00
Oskar Otwinowski d28707826c feat(webapp): add GitHub onboarding flow to empty Tasks and Deployments pages (#2775)
##  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

Set up the local github application, and tested its connection with
trigger.dev

Checked:
- Change is backwards compatible
- Actions/ux are uniform across the pages (empty Tasks, Deployments,
project settings)
- Connecting GH, Connecting Repo, disconnecting Repo, modifying settings

---

## Changelog

- Create new resource route for GitHub settings management with loader
and actions
- Add GitHubSettingsPresenter to fetch connected repos and installations
- Implement GitHubSettingsPanel component for reusable GitHub
configuration UI
- Refactor project settings page to use shared GitHubSettingsPanel
component
- Integrate GitHub connection flow into empty state onboarding for Tasks
and Deployments
- Add support for GitHub repo connection, disconnection, and branch
tracking settings
- Include redirect URL support for seamless navigation after GitHub
actions
- Remove duplicate GitHub connection code from project settings route

---

## Screenshots


https://github.com/user-attachments/assets/8fc24699-640b-4f9e-afd8-b26edc945218

🐐

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-10 17:03:16 +00:00
Max Strübing 8b00198f99 docs(kubernetes): use v4 helm chart instead of beta (#2671) 2025-12-10 14:52:32 +00:00
nicktrn 74e9246bfa feat(cli): enable zstd compression for deployment images (#2773)
This will speed up ice cold starts (*) for two reasons:
- better compression ratio
- faster decompression

This is a minor release because zstd compression will now be enabled by
default for all deployments.

(*) ice cold starts happen when deploy images are not cached on the
worker node yet. These cold start durations are highly dependent on
image size and as it turns out, also the type of compression used.
2025-12-10 14:49:44 +00:00
Mihai Popescu 07a31d3732 fix(webapp) : fixed Cmd+Left Arrow was intercepted by the TreeView component (#2772)
Fixed the issue where Cmd+Left Arrow was being intercepted by the
TreeView component on task runs screen.

Solution:
Added a check in the getTreeProps keyboard handler to detect when
metaKey (Cmd on macOS) is pressed with Left Arrow. When detected, the
handler returns early without preventing the default browser behavior,
allowing Chrome's native back navigation to work.


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

Went to the task details page and confirmed that Cmd + Left Arrow will
navigate back

---

## Changelog

Modified TreeView.tsx to check for e.metaKey before handling Left Arrow
key events
When Cmd+Left is pressed, the event is no longer prevented, allowing
browser default behavior
---

Co-authored-by: Mihai Popescu <mihaipopescu@Mihais-MacBook-Pro.local>
2025-12-10 12:05:16 +00:00
Saadi Myftija da111e220f fix(api): whitelist deployment endpoints from the general API rate limits (#2774)
Deployments are affected by general API rate limits, this is just a
quick fix by whitelisting the deployment related endpoints. In a follow
up PR we'll add a separate rate limiter for this group of endpoints.
2025-12-10 12:03:56 +01:00
Oskar Otwinowski 2c3cb4a43a feat(webapp): UX improvements for TaskRun page and TaskRun table (#2760)
##  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

Manual testing of the task run pages

---

## Changelog

- Add previous/next run navigation buttons to run detail page header
- Support [ and ] keyboard shortcuts to jump between adjacent runs
- Preserve runs table state (filters, pagination) when navigating
- Preload adjacent page runs at boundaries for seamless navigation
- Add actions prop to PageTitle component
- Document shortcut in keyboard shortcuts panel

- Store current filter state from runs table as `tableState` search
param when navigating to individual run pages
- Restore filters when navigating back from run detail view to runs list
- Update `v3RunPath` and `v3RunSpanPath` helpers to accept optional
searchParams
- Use `useOptimisticLocation` to capture current search params in
TaskRunsTable
- Parse `tableState` param in run detail route and pass filters to back
button

- This improves UX by remembering filter selections (task, status, date
range, etc.) when users click into a run and then navigate back to the
runs list

- Add new text-below variant that shows "Click to copy" tooltip on hover
and "Copied" on click. Also add controlled open/onOpenChange props to
SimpleTooltip for managing tooltip visibility.

---

## Screenshots


https://github.com/user-attachments/assets/5067bbe0-1bcd-4e75-80a7-f56dabd5ed69
2025-12-09 13:10:11 +00:00
Saadi Myftija b71bf89444 chore(releases): minor improvements to the release workflow (#2764)
Changes in this PR:
- Arbitrary refs are now allowed when triggering the release workflow
manually (ref must be on the main branch).
- Release summary is now displayed in the GH job output; makes for a
nicer experience when approving the release workflow.
2025-12-09 11:29:48 +01:00
Eric Allam 28c0c78257 fix(api): triggering a batch on a v4 project via the v3 SDK no longer results in errors (#2752)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Updates `BatchTriggerV3Service` to send `batch.id` (not `friendlyId`)
as `batchId` to `TriggerTaskService.call`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
68bf8df4a29bdcac5bfb2806bc417541f69ffd6e. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:39 +00:00
Eric Allam c021d1db63 fix(dev): stop max listeners exceeded warning messages when running more than 10 runs concurrently (#2771)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Centralizes SIGTERM handling in `DevSupervisor` and removes per-run
SIGTERM listeners in `DevRunController` to avoid
MaxListenersExceededWarning under high concurrency.
> 
> - **Dev runtime**:
> - **SIGTERM handling**: Add centralized handler in
`packages/cli-v3/src/dev/devSupervisor.ts` to gracefully stop all run
controllers; unregisters on `shutdown()`.
> - **Cleanup**: Remove per-controller `SIGTERM` listener and handler
from `packages/cli-v3/src/entryPoints/dev-run-controller.ts` to reduce
event listeners and warnings.
> - **Changeset**: Add patch note in
`.changeset/fuzzy-ghosts-admire.md`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5ad2f5341829cebf6fd37a3c616a2db5e4ad936a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-09 09:54:28 +00:00
Eric Allam f62cdfe00e feat(dashboard): login with google and "last used" indicator (#2746)
<img width="568" height="513" alt="CleanShot 2025-12-05 at 14 27 16"
src="https://github.com/user-attachments/assets/1f44d8b9-8791-4b44-96d5-4a0960a1ab36"
/>

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Google OAuth login and a cookie-based “last used” indicator on
the login page, with supporting backend, routes, and schema updates.
> 
> - **Auth/Backend**:
> - **Google OAuth**: Integrates `remix-auth-google` via new
`addGoogleStrategy` and enables when `AUTH_GOOGLE_CLIENT_ID/SECRET` are
set (`services/googleAuth.server.ts`, `services/auth.server.ts`).
> - **User handling**: Implements `findOrCreateGoogleUser` with
linking/upsert logic and conflict logging (`models/user.server.ts`).
> - **MFA + session**: Google/GitHub/Magic callbacks now set session,
handle MFA, and set a "last-auth-method" cookie
(`routes/auth.google*.tsx`, `routes/auth.github.callback.tsx`,
`routes/magic.tsx`, `services/lastAuthMethod.server.ts`).
> - **GitHub strategy**: Safer email check
(`services/gitHubAuth.server.ts`).
> - **Routes/UI**:
> - **Login page**: Adds "Continue with Google" button and animated
"Last used" badge based on cookie; keeps GitHub/Email options
(`routes/login._index/route.tsx`).
> - **Redirect safety**: Sanitize redirect paths and persist redirect
via cookies in auth actions (`routes/auth.github.ts`,
`routes/auth.google.ts`).
>   - **Assets**: Adds `GoogleLogo` SVG.
>   - **Avatar**: Set `referrerPolicy="no-referrer"` on profile image.
> - **Config/Schema**:
> - **Env**: Adds `AUTH_GOOGLE_CLIENT_ID`/`AUTH_GOOGLE_CLIENT_SECRET`
(`env.server.ts`).
> - **DB**: Extends `AuthenticationMethod` enum with `GOOGLE` (Prisma
schema + migration).
> - **Dependencies**:
>   - Adds `remix-auth-google` in `package.json`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9f84f974bd6f21f1699c4f69a6aa91616842d1b1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-12-09 09:51:42 +00:00
nicktrn 66c6da7114 security: dependabot alert triage (#2768)
- Upgrade @modelcontextprotocol/sdk to 1.24.3
- Override jws to 3.2.3
2025-12-08 21:02:13 +00:00
Saadi Myftija 7fba9e9f6b feat(deployments): add build server meta and trigger source info (#2767)
This PR applies a small change to the deployments table to keep track
of:
- where the deployment was triggered from
- build server metadata, if the build server was involved
2025-12-08 16:29:26 +00:00
Saadi Myftija cf63fc9cd2 chore: update version requirement hint in build settings (#2757) 2025-12-08 09:27:28 +01:00
Saadi Myftija d1c3bfb9c9 fix(deployments): ecr repo exists check (#2762)
We recently upgraded the ECR sdk version. Our ECR repo exists check
relies on the type of the error thrown and the new ECR sdk version seems
to have broken that behavior. This PR adds a workaround to the issue.
2025-12-05 19:16:28 +01:00
Saadi Myftija d8f5853457 fix(releases): use contents: write permission in the release workflow (#2761)
Needed to push tags
2025-12-05 17:22:24 +01:00
Saadi Myftija a52566d9cc feat(releases): add a manual trigger for the package release workflow (#2759)
The manual trigger is currently enabled only for the prerelease job.
This PR adds it for the normal release flow too, as it is useful to
retrigger failed release workflows.
2025-12-05 17:00:02 +01:00
Saadi Myftija 07a1d04d52 fix(releases): add missing npm upgrade step (#2758)
Accidentally removed this in a previous PR.
2025-12-05 16:41:45 +01:00
github-actions[bot] 185f4ecaf9 chore: release v4.2.0 (#2713)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 19s
🚀 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@4.2.0

### Minor Changes

- feat(cli): upgrade bun deployments to v1.3.3
([#2756](https://github.com/triggerdotdev/trigger.dev/pull/2756))

### Patch Changes

- fix(otel): exported logs and spans will now have matching trace IDs
([#2724](https://github.com/triggerdotdev/trigger.dev/pull/2724))
- The `--force-local-build` flag is now renamed to just `--local-build`
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))
- fix(cli): header will always print the correct profile
([#2728](https://github.com/triggerdotdev/trigger.dev/pull/2728))
- feat: add ability to set custom resource properties through
trigger.config.ts or via the OTEL_RESOURCE_ATTRIBUTES env var
([#2704](https://github.com/triggerdotdev/trigger.dev/pull/2704))
- feat(cli): implements content-addressable store for the dev CLI build
outputs, reducing disk usage
([#2725](https://github.com/triggerdotdev/trigger.dev/pull/2725))
- Added support for native build server builds in the deploy command
(`--native-build-server`)
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))
-   Updated dependencies:
    -   `@trigger.dev/build@4.2.0`
    -   `@trigger.dev/core@4.2.0`
    -   `@trigger.dev/schema-to-json@4.2.0`

## @trigger.dev/build@4.2.0

### Patch Changes

- syncVercelEnvVars to skip API and read env vars directly from
env.process for Vercel build environments. New syncNeonEnvVars build
extension for syncing environment variablesfrom Neon database projects
to Trigger.dev. The extension automatically detects branches and builds
appropriate PostgreSQL connection strings for non-production, non-dev
environments (staging, preview).
([#2729](https://github.com/triggerdotdev/trigger.dev/pull/2729))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

## @trigger.dev/core@4.2.0

### Patch Changes

- fix: prevent ERR_IPC_CHANNEL_CLOSED errors from causing an unhandled
exception on TaskRunProcess
([#2743](https://github.com/triggerdotdev/trigger.dev/pull/2743))
- Added support for native build server builds in the deploy command
(`--native-build-server`)
([#2702](https://github.com/triggerdotdev/trigger.dev/pull/2702))

## @trigger.dev/python@4.2.0

### Patch Changes

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

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

### Patch Changes

- fix: prevent infinite useEffect when passing an array of tags to
useRealtimeRunsWithTag
([#2705](https://github.com/triggerdotdev/trigger.dev/pull/2705))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.0`

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

### Patch Changes

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

## @trigger.dev/rsc@4.2.0

### Patch Changes

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

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

### Patch Changes

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

## @trigger.dev/sdk@4.2.0

### Patch Changes

- fix(sdk): Re-export schemaTask types to prevent the TypeScript error
TS2742: The inferred type of 'task' cannot be named without a reference
to '@trigger.dev/core/v3'. This is likely not portable.
([#2735](https://github.com/triggerdotdev/trigger.dev/pull/2735))
- feat: add ability to set custom resource properties through
trigger.config.ts or via the OTEL_RESOURCE_ATTRIBUTES env var
([#2704](https://github.com/triggerdotdev/trigger.dev/pull/2704))
-   Updated dependencies:
    -   `@trigger.dev/core@4.2.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>
2025-12-05 16:27:21 +01:00
nicktrn 0d764d4c46 feat(cli): upgrade bun deployments to v1.3.3 (#2756)
New deployments with `runtime: "bun"` will now use Bun v1.3.3

Link to Bun release: https://bun.com/blog/bun-v1.3.3
2025-12-05 13:31:27 +00:00
Saadi Myftija beb52b9800 chore(releases): adjust gh workflow title (#2755)
Just a tiny workflow label change
2025-12-05 13:33:35 +01:00
Saadi Myftija 3401a1d0a9 feat(releases): require approval for package releases (#2753)
**Background**
Currently the changeset PR creation and the publishing is handled by the
same workflow. This is not ideal:
- The build steps are executed on every run of the pipeline, even though
they're only needed for the publish case.
- The PR creation workflow does not need permissions to publish to npm,
only the release path needs them.
- Adding an approval step is painful as we'd need to also approve each
changeset PR creation workflow run.

**Changes in this PR**
- Separated the changeset PR creation into its own workflow and minimum
permission set.
- Added a GH environment with an approval step for the package
publishing workflow (also for prereleases).
- New publish workflow runs will not cancel in-progress runs; helps
avoid partial failures in publishing.

These changes also enable hardening the npm OIDC setup by tying it to a
GH environment that requires approval.
2025-12-05 13:25:25 +01:00
nicktrn 3f982ed366 docs: update email regex examples (#2749)
The previous examples lacked start and end anchors
2025-12-04 17:58:25 +00:00
Saadi Myftija 4dc956470d fix(deployments): misc fixes for the native build server deployment flow (#2748)
Improved a couple of error messages.

Also fixed an issue with the s2 token caching.
2025-12-04 17:01:47 +01:00
Oskar Otwinowski 7fddadcce9 chore: Improve Vercel/Neon syncEnvVars build extensions docs (#2747)
chore: Improve Vercel/Neon `syncEnvVars` build extensions docs
2025-12-04 16:34:57 +01:00
Oskar 6e038d4d1e chore: Improve Vercel/Neon syncEnvVars extensions docs 2025-12-04 16:32:15 +01:00
Oskar Otwinowski 05b53967ea feat(build): Add NeonDB branch resolution for Vercel preview environments (#2729)
Vercel's NeonDB integration renders database connection environment
variables at runtime, which means Trigger.dev cannot directly sync these
values during the build process. This change adds support for fetching
branch-specific NeonDB connection strings via the Neon API.

feat(build): Add syncNeonEnvVars extension and improve Vercel env var
syncing

Add a new `syncNeonEnvVars` build extension for syncing environment
variables
from Neon database projects to Trigger.dev. The extension automatically
detects
branches and builds appropriate PostgreSQL connection strings for
non-production
environments (staging, dev, preview).

Features of `syncNeonEnvVars`:
- Fetches branch-specific database credentials from Neon API
- Generates all standard Postgres connection strings (DATABASE_URL,
POSTGRES_URL,
  POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants
- Supports custom database name, role name, and env var prefix options
- Skips automatically in Vercel environments (Neon's Vercel integration
handles this)
- Skips for production environments (designed for preview/staging/dev
branches)

Improvements to `syncVercelEnvVars`:
- When running in a Vercel build environment (detected via VERCEL env
var),
values are now read from process.env instead of the Vercel API response
- This ensures the build uses the actual runtime values Vercel provides
- Removed embedded Neon-specific logic (now handled by separate
extension)
- Simplified and cleaned up the extension code

Documentation updates for both extensions with usage examples and
configuration
options.

Closes #2714

##  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

Set up Vercel + Trigger.dev envs, used Vercel's chat-bot-ai template.
2025-12-04 16:26:35 +01:00
Oskar 702f3b4bca chore: Improve changeset message 2025-12-04 16:26:24 +01:00
nicktrn 8fcd93001d chore: insert missing spaces in changeset 2025-12-04 13:42:32 +00:00
Eric Allam b01b8740cc fix(engine): prevent overriding a queue concurrency limit resuming a paused queue (#2745) 2025-12-04 13:33:12 +00:00
Oskar 357aa99309 Add changeset 2025-12-04 14:12:42 +01:00
Oskar 7cbf82a4ae fix: PR feedback, doc improvements for neon/vercel syncEnvVars 2025-12-04 14:07:31 +01:00
Eric Allam e7fec4097f fix(dev): CLI now properly cleans up the store dir on dev CLI exit (#2744) 2025-12-04 13:13:01 +01:00
Eric Allam d279988e38 fix(workers): prevent ERR_IPC_CHANNEL_CLOSED errors from causing an unhandled exception on TaskRunProcess (#2743) 2025-12-04 12:02:30 +00:00
Saadi Myftija 748ae658f7 fix(releases): use npm 11.x for OIDC support in the release workflow (#2742)
Support for OIDC requires npm v11.5.1 or newer.
2025-12-04 12:21:21 +01:00
Oskar Otwinowski 249878ed92 feat(webapp): link to task-specific test page from filtered runs table (#2741)
When viewing runs filtered to a single task, the "Create a test run" and
"Run a test" buttons now navigate directly to the task-specific test
page instead of the generic test page.

This improves UX by pre-populating the test form with the filtered task,
saving users from having to manually select it again.


##  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

## Screenshots

<img width="1706" height="1392" alt="image"
src="https://github.com/user-attachments/assets/d8b5d445-73b5-426c-83a4-90ac2a95b955"
/>
2025-12-04 10:56:24 +00:00
Eric Allam 117b1d5a53 chore(dependabot): upgrade next.js for CVE-2025-66478 in d3-chat example project (#2740) 2025-12-04 10:30:37 +00:00
Eric Allam 04173a93b9 fix(replication): detect misconfigered run replication publication and output helpful error messages (#2736)
Add validation for logical replication publication configuration. Helps
diagnose an issue where runs are no longer replicated to clickhouse
because of a configuration issue with the replication publication.

## Problem

The `LogicalReplicationClient` only checked if a publication existed,
not if it was correctly configured. This caused a silent failure where:

- Replication would start successfully
- Transaction boundaries (begin/commit) were received
- **But no actual data changes were replicated**

This happened when a publication existed but:
1. Had no tables associated with it
2. Was missing required actions (e.g., `delete`)

## Solution

Added `#validatePublicationConfiguration()` method that validates:
-  Publication includes the expected table
-  Publication has all required actions configured

When validation fails, error messages include the exact SQL command to
fix the issue:

**Missing table:**
```
Publication 'task_runs_to_clickhouse_v1_publication' exists but has NO TABLES configured. 
Expected table: "public.TaskRun". 
Run: ALTER PUBLICATION task_runs_to_clickhouse_v1_publication ADD TABLE "TaskRun";
```

**Missing actions:**
```
Publication 'task_runs_to_clickhouse_v1_publication' is missing required actions. 
Expected: [insert, update, delete], Current: [insert, update], Missing: [delete]. 
Run: ALTER PUBLICATION task_runs_to_clickhouse_v1_publication SET (publish = 'insert, update, delete');
```

This prevents silent data loss and makes debugging configuration issues
much easier.
2025-12-04 10:29:13 +00:00
Saadi Myftija da4c753b68 fix(releases): add missing GH token in prerelease workflow (#2739)
The `changeset version` command needs the GH token too.
2025-12-04 11:27:11 +01:00
Eric Allam 6ae3b69745 chore(cursor): add deslop command (#2722) 2025-12-04 10:24:09 +00:00
Saadi Myftija 652d95c7eb feat(releases): add a prerelease workflow (#2737)
Adds a manual trigger to the `release.yml` workflow for publishing
prerelease versions. Needs to be in the same workflow file due to a NPM
limitation on how OIDC claims are checked.

Currently there is a validation step on the ref for the prerelease: it
must be merged to the main branch. We can revisit this in the future in
case we find it too limiting.
2025-12-04 10:17:05 +01:00
Saadi Myftija 341e27d213 chore(deployments): lazily update ECR repo cache settings (#2734)
To avoid doing a migration for the ECR repo cache settings, we lazily do
it on the next deployment for that project. Failures to update the repo
settings are just logged and will not cause the deployment to fail.
2025-12-03 17:15:59 +01:00
Eric Allam 9821d02af7 fix(sdk): Re-export schemaTask types to prevent the TypeScript error TS2742 (#2735)
Fixes this type of error when exporting a `schemaTask` in a monorepo:

```
error TS2742: The inferred type of 'helloWorldSchema' cannot be named without a reference to '@trigger.dev/core/v3'. This is likely not portable.
```
2025-12-03 16:12:23 +00:00
Saadi Myftija 255a73a2fe feat(deployments): --native-build-server support for the deploy command (#2702)
This PR adds support for CLI deployments using the native build server.

**Background**

The deployment command currently does the following:
- bundles the code
- submits the build context to our external build provider and waits for
the build
- triggers deployment state transitions using the platform API

Upstream build provider outages cause issue with deployments,
potentially blocking deployments entirely. We recently introduced the
`--force-local-build` flag as a fallback to enable deployment without a
dependency on the upstream build provider, though it requires users to
have docker in their systems. This PR continues that work by providing a
remote build path which uses our own build server and does not rely on
the external provider.

**Changes in this PR**

Introduced the new `--native-build-server` flag, which does the
following:
- scans all files relevant for the Trigger deployment and evaluates
ignore rules
- packages it up in an archive and uploads it as a deployment artifact
- queues the deployment and triggers the build
- streams logs from the build server

This no longer relies on external build services. Also deployment state
transitions happen on the server-side, giving us more flexibility to
evolve the flow and schemas of related deployment API endpoints. In
general it gives us better control of the whole build and deployment
process. This path will eventually become the default.

The `--detach` flag is also new, allowing to trigger deployments without
waiting for the result.

The deployment artifacts are uploaded via pre-signed URLs to avoid
unnecessary load on the platform. The new `/artifacts` endpoint
generates the pre-signed URLs; size limits are enforced on s3. This
endpoint is deliberately generic, we could extend it in the future to
upload other artifacts client-side in a similar way, e.g., large payload
packets.
2025-12-03 16:40:21 +01:00
Saadi Myftija c5f7a8daf7 fix(releases): explicitly use no frozen lockfile in the install step (#2733) 2025-12-03 16:28:06 +01:00
Saadi Myftija 8b0f51b317 chore(releases): automatically update the lockfile in changeset PRs (#2732)
This is a step which we currently need to do manually and it's rather
painful. The lockfile update is necessary due to cross references in our
packages.

Added it as a separate job instead of a step to start from fresh
workspace, as the state that the `changeset` step leaves the workdir is
not explicitly clear to the reader.
2025-12-03 16:04:30 +01:00
Oskar 53f21e1330 feat(build): Add syncNeonEnvVars extension and improve Vercel env var syncing
Add a new `syncNeonEnvVars` build extension for syncing environment variables
from Neon database projects to Trigger.dev. The extension automatically detects
branches and builds appropriate PostgreSQL connection strings for non-production
environments (staging, dev, preview).

Features of `syncNeonEnvVars`:
- Fetches branch-specific database credentials from Neon API
- Generates all standard Postgres connection strings (DATABASE_URL, POSTGRES_URL,
  POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants
- Supports custom database name, role name, and env var prefix options
- Skips automatically in Vercel environments (Neon's Vercel integration handles this)
- Skips for production environments (designed for preview/staging/dev branches)

Improvements to `syncVercelEnvVars`:
- When running in a Vercel build environment (detected via VERCEL env var),
  values are now read from process.env instead of the Vercel API response
- This ensures the build uses the actual runtime values Vercel provides
- Removed embedded Neon-specific logic (now handled by separate extension)
- Simplified and cleaned up the extension code

Documentation updates for both extensions with usage examples and configuration
options.
2025-12-03 16:03:41 +01:00
Saadi Myftija 331882f59c chore(releases): add package version in changeset PRs (#2730)
* Add the release version to changeset PRs

* Add missing id-token permission, needed for oidc

* Remove a couple of unnecesary steps

* Reference the `changeset-release/main` branch explicitly
2025-12-03 14:42:40 +01:00
nicktrn 1276491a83 docs: clarify slack channel on pro (#2731) 2025-12-03 13:23:38 +00:00
Eric Allam 5b7dfe23b5 feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage (#2725)
* feat(cli): implements content-addressable store for the dev CLI build outputs, reducing disk usage

* fix a few things
2025-12-03 10:17:35 +00:00
Oskar 49b2f683f4 feat(build): Add NeonDB branch resolution for Vercel preview environments
Vercel's NeonDB integration renders database connection environment
variables at runtime, which means Trigger.dev cannot directly sync
these values during the build process. This change adds support for
fetching branch-specific NeonDB connection strings via the Neon API.

Changes:
- Discover NEON_PROJECT_ID from incoming Vercel environment variables
- Call NeonDB API to search for branches matching the git branch name
- Filter branches to find exact matches with Vercel environment prefix
  (e.g., "preview/branch-name") to avoid false positives from partial
  string matches
- Retrieve branch endpoints and select the write endpoint (or first
  available)
- Build connection strings (DATABASE_URL, POSTGRES_URL, etc.) using
  the branch endpoint host while preserving user/password credentials

Safety measures for non-production environments:
- Filter out all Neon-related env vars (DATABASE_URL, PGHOST, etc.)
  before calling the Neon API to prevent accidental use of production
  database credentials
- Only add branch-specific database env vars if a matching Neon branch
  is found and the API call succeeds
- If neonDbAccessToken is not provided or the API fails, non-production
  environments will not receive any database connection env vars

Usage:
Users must provide a NEON_ACCESS_TOKEN (via options or env var) to
enable automatic branch resolution for preview deployments. Production
environments continue to use Vercel's standard env var sync without
modification.
2025-12-02 19:52:45 +01:00
nicktrn af9b3e1c99 fix(cli): header will always print the correct profile (#2728) 2025-12-02 17:45:35 +00:00
Eric Allam df4ab97d59 fix(otel): fix broken schedule run spans (#2727)
schedule spans can sometimes show as generic spans when using the 
task_events_v2 table because of the inserted_at filter. Increasing the 
buffer for the start time does the trick and doesn’t cause any perf 
Issues (and is in general just more robust)
2025-12-02 17:16:08 +00:00
Eric Allam 9f27422472 fix(otel): exported logs and spans will now have matching trace IDs (#2724)
When using custom OTLP exporters via `telemetry.exporters` and 

This occurred when tasks were triggered **without** a parent trace 
context (e.g., via API or dashboard). In this scenario: - Spans were 
correctly rewritten to use the generated `externalTraceId` - Logs kept 
their original internal trace ID due to a bug in the early return logic

### Root Cause

In `ExternalLogRecordExporterWrapper.transformLogRecord()`, the early 
return condition incorrectly included `!this.externalTraceContext`:

```typescript
if (!logRecord.spanContext || !this.externalTraceId || 
!this.externalTraceContext) {   return logRecord;  // Bug: Returns early
when externalTraceContext is undefined }

// This fallback logic was never reached:
const externalTraceId = this.externalTraceContext
  ? this.externalTraceContext.traceId
  : this.externalTraceId;
```

### Fix

1. **Reordered logic in `transformLogRecord()`**: Move the 
1. `externalTraceId` calculation before the early return, and check the 
1. culated value instead of `this.externalTraceContext`:

```typescript
const externalTraceId = this.externalTraceContext
  ? this.externalTraceContext.traceId
  : this.externalTraceId;

if (!logRecord.spanContext || !externalTraceId) {
  return logRecord;
}
```

2. **Clarified `_isExternallySampled` logic**: Updated both 
2. `ExternalSpanExporterWrapper` and `ExternalLogRecordExporterWrapper` 
2. explicitly handle the case where there's no external trace context 
2. a generated `externalTraceId` exists:

```typescript
this._isExternallySampled = externalTraceContext
  ? isTraceFlagSampled(externalTraceContext.traceFlags)
  : !!externalTraceId;
```

### Impact

Logs and spans from the same task run will now have matching trace IDs 
when exported to external observability tools, enabling proper trace correlation regardless of whether the task was triggered with or without a parent trace context. 
`telemetry.logExporters` in `trigger.config.ts`, logs and spans were 
exported with **different trace IDs**, breaking trace correlation in
external observability tools like Datadog.
2025-12-02 14:16:06 +00:00
nicktrn 2f1a72b109 security: remedy dependabot alerts (#2723)
* security: override js-yaml

* security: upgrade vite

* security: update nodemailer
2025-12-02 12:02:48 +00:00
Eric Allam 3c326a4b4a fix(clickhouse): ensure start_time is never older than X ms to prevent old partition merge issues (#2721) 2025-12-01 15:43:10 +00:00
Saadi Myftija 6ae1317b69 chore(clickhouse): enable dropping run debug events (#2720)
Added a new env var (`EVENT_REPOSITORY_DEBUG_LOGS_DISABLED`) that allows disabling writing run debug logs in the event repository.
2025-12-01 15:42:35 +01:00
Eric Allam 2e1c4f6df6 fix(clickhouse): partition by insertion date to prevent "Too many parts" errors when partitioning by start time (#2719) 2025-12-01 12:01:06 +00:00
nicktrn 2bf86dc20e fix(supervisor): image builds with pnpm v10 (#2718) 2025-12-01 11:28:05 +00:00
nicktrn 485782cae1 feat(ch): optionally disable migrations (#2715) 2025-11-28 19:39:29 +00:00
Eric Allam 61b338bea7 chore(repo): upgrade repo to pnpm@10 to prevent executing dep scripts on install (#2712)
* chore: migrate pnpm lockfile to v9 format via pnpm@9

* Upgrade to pnpm 10.23.0

* update the dockerfile and added a few deps to bundle in remix app
2025-11-27 16:26:19 +00:00
Kim Hallberg 83ddf721a4 docs: update Firecrawl example (#2652)
Update the example to use Firecrawl v2 API
2025-11-24 14:54:51 +00:00
Kim Hallberg a4dd2562d2 docs: format embedding example (#2656) 2025-11-24 14:51:53 +00:00
Felipe Martinez Albeche 5ff21a758c fix(docs): tooltip color contrast accessibility (#2662)
* fix(docs): color contrast accessibility on tooltip

* add changeset

* Delete .changeset/tough-feet-accept.md

---------

Co-authored-by: Eric Allam <eric@trigger.dev>
2025-11-24 14:39:14 +00:00
Lindsey 47a64c0335 docs: improve env var documentation with .env upload + ctx environment details (#2680) 2025-11-24 14:37:04 +00:00
Eric Allam fc351cb6c4 chore(repo): remove GITHUB_TOKEN requirement for publishing prerelease packages (#2679) 2025-11-24 14:33:40 +00:00
Eric Allam c4f2a9d065 chore(references): added prisma-generator-ts-enums to prisma ref project as an example (#2701) 2025-11-24 14:33:22 +00:00
Eric Allam 2762c542c2 chore(repo): remove format script to AI stops calling it (#2703) 2025-11-24 14:33:14 +00:00
Eric Allam 72e286af2f feat(otel): support for custom resource attributes via config#telemetry.resource and OTEL_RESOURCE_ATTRIBUTES env var (#2704) 2025-11-24 14:33:07 +00:00
Eric Allam f7240a99e7 fix(react): prevent infinite useEffect when passing an array of tags to useRealtimeRunsWithTag (#2705) 2025-11-24 14:32:57 +00:00
github-actions[bot] 6e47377766 chore: Update version for release (#2700)
🚀 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 21s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-24 10:30:01 +00:00
nicktrn a8563ca534 fix(docker): support the latest docker version (#2686) 2025-11-21 15:24:31 +00:00
Eric Allam 5e5c97ea4c fix(cli): stop dev runs stuck in dequeued status (fix #2639) (#2699)
* stop deleting the first dev version files on the first change, prevents system failures

* prevent dev runs getting stuck in dequeued status by deleting workers

* add changeset
2025-11-21 15:24:07 +00:00
Matt Aitken abd99aa27f Don't show (or count) archived branches/projects against allocated concurrency (#2698)
Archived preview branches were being counted. Also archived projects.
2025-11-20 11:41:12 +00:00
Eric Allam 4347499799 fix(api): Fix preview branch targeting in environment variable API routes (#2697)
This PR fixes the `x-trigger-branch` header support for targeting specific preview branches when managing environment variables. The header was documented but not actually being extracted or used in the environment variable API routes. Additionally, the query logic in `authenticatedEnvironmentForAuthentication` was fundamentally broken—it searched for environments with both `slug: "preview"` (parent environment property) AND a specific `branchName` (child environment property), which no environment could satisfy simultaneously. The fix extracts the branch name using `branchNameFromRequest()` and correctly queries for child branch environments using `type: "PREVIEW"` and the specific `branchName`. This ensures that environment variable operations (create, update, get, list) properly target individual preview branches instead of affecting all preview environments.
2025-11-19 18:01:12 +00:00
Eric Allam abee783d3f chore(logs): remove unnecessary debug logs (#2696) 2025-11-19 13:56:15 +00:00
Eric Allam 6464eeed53 fix(webapp): correctly generate JWT tokens for preview branches after triggering a run (fix #2678) (#2695) 2025-11-19 13:42:12 +00:00
Matt Aitken bee59de3a0 Concurrency self serve (#2681)
* Don't use the organization max concurrency anymore

* Early draft of the concurrency page

* WIP adding a new stepper input component

* Move stepper to be alphabetical

* When max value is reached, disabled the + button

* Show placeholder if you delete all numbers

* Make all the html input values available to the component

* Adds size variants

* Move stepper into its own component

* Work on showing the extra concurrency

* The purchase form styling and functionality (minus actually purchasing)

* New style for outline input fields

* Concurrency purchasing working

* Purchasing concurrency and quota emails working

* Improvements to the modal

* Show cost breakdown in the modal

* Fix for allocated concurrency including DEV

* Improved types

* Allocating concurrency is working

* Live updates total env concurrency

* Implemented reset

* Fix for concurrency allocation editing across multiple projects

* Tabular numbers

* Added an error from allocating concurrency

* Fixes for allocating concurrency where it didn't calculate correctly

* "Increase limit" link to concurrency page

* Indent environments

* Added Preview limit when updating concurrency for an org

* Show error when changing plan fails

* Added maximumProjectCount column to Org

* Limit project count and display a rich error toast (with title and button now)

* Added title and button to toasts. Use it for new project error

* @trigger.dev/platform 1.0.20

* Allow submitting zero concurrency so you can downgrade back to nothing

* Use the server as the truth for omitted environments

* Updated the pricing panels

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2025-11-19 11:22:23 +00:00
Eric Allam 3af7303156 feat(docs): update prismaExtension for new mode functionality in 4.1.1 (#2690) 2025-11-19 11:16:35 +00:00
github-actions[bot] 6231ddc67a Release v4.1.1
* chore: Update version for release

* chore: Update pnpm-lock.yaml (#2694)

* Initial plan

* Update pnpm-lock.yaml

Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ericallam <534+ericallam@users.noreply.github.com>
2025-11-19 11:09:26 +00:00
Eric Allam 15fef916f6 feat(build): update prisma extension to work with generated clients and rust-free clients (#2689)
* prisma extension fixes WIP

* More prisma stuff

* more prisma stuff

* remove changelog

* upgrade github workflows to use node 20.19 because installing prisma@7 breaks with lower versions

* Don't use generate for the prisma reference projects

* make sure it works if no mode is passed in
2025-11-19 10:48:15 +00:00
Eric Allam e2a703bfa8 fix(dashboard): continuously apply log filter during live run (#2692) 2025-11-18 17:00:29 +00:00
Saadi Myftija 1a7ee24b9e fix(ui): refresh deployments logs stream on status change (#2688)
The logs stream is created after the deployment moves from `PENDING` status to `INSTALLING`.
2025-11-17 14:50:05 +01:00
Saadi Myftija bb99af52cb feat(deployments): expose native build server option in build settings (#2685)
Adds a build setting about using our build server for remote builds.
2025-11-17 12:46:57 +01:00
Saadi Myftija 01797a1668 feat(deployments): ECR repo adaptations to enable external build cache (#2684)
* Update aws sdk ecr client to the latest version

* Exlude the cache tag from the immutability enforcement

* Attach a policy to ECR repos to expire untagged images

* Fix filterType
2025-11-17 12:34:54 +01:00
Eric Allam 19fa669318 Release v4.1.0 (#2683) 2025-11-14 09:55:01 +00:00
github-actions[bot] 78fcba518a chore: Update version for release (#2666)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-14 09:52:22 +00:00
Saadi Myftija 53047ab648 feat(deployments): external cache support for local builds (#2682)
* Add registry cache support for local builds

* Add changeset
2025-11-14 10:46:31 +01:00
Eric Allam bb5cefa92c docs: realtime streams v2 (#2673) 2025-11-14 09:42:20 +00:00
Eric Allam b8b198579c chore(rules): upgrade rules for 4.1.0 (#2676) 2025-11-14 09:42:15 +00:00
Eric Allam 892bed8c4c Upgrade to electricsql 1.2.4 (#2668) 2025-11-13 15:19:59 +00:00
Eric Allam a94a11f44d feat(sdk): replace onStart lifecycle hook with onStartAttempt (#2515)
* fix(sdk): prevent uncaught errors thrown onSuccess, onComplete, and onFailure hooks to fail attempts & in some cases runs

* Add onStartAttempt hook and deprecate onSuccess

* Add onStartAttempt hook and deprecate onStart hook

* Fix onStartAttempt overload types

* Update lifecycle functions diagram
2025-11-13 14:51:13 +00:00
Matt Aitken f116e93e01 Docs: tags can be up to 128 chars (#2678) 2025-11-13 14:43:31 +00:00
Eric Allam 6137338da9 feat(streams): make v2 streams the default when using 4.1.0+ if they are supported (#2677) 2025-11-13 13:53:42 +00:00
Lindsey 8cec3b763b docs: add section on using .env.production and dotenvx for environmen… (#2674)
* docs: add section on using .env.production and dotenvx for environment variables

* fix: correct dotenvx API usage in documentation example
2025-11-13 11:28:26 +00:00
Eric Allam 343ba54c69 fix(streams): restore realtime stream writing for v3 tasks (#2675) 2025-11-13 11:21:48 +00:00
Eric Allam a70ab10809 fix(streams): fixed broken wrapping in streams inspector (#2672) 2025-11-12 16:00:54 +00:00
Eric Allam f7cb637b32 fix(streams): scope s2 access token to environment and fix streams v1 appends (#2670)
* fix(streams): scope s2 access token to environment and fix streams v1 appends

* Less stale time
2025-11-12 12:34:15 +00:00
Eric Allam 668559ec1a fix(streams): buffer v1 streams on read to prevent split chunks (#2669) 2025-11-11 21:08:49 +00:00
Eric Allam d0ad38d684 chore(docker): remove unused seed copy from dockerfile (#2667) 2025-11-11 15:13:10 +00:00
Eric Allam 536d9fa217 feat(realtime): Realtime streams v2 (#2632) 2025-11-11 14:54:00 +00:00
github-actions[bot] d75c3aeadd chore: Update version for release (#2665)
* chore: Update version for release

* Release 4.0.7

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2025-11-11 14:29:24 +00:00
Matt Aitken a342332146 Fix for the MCP tool that gets logs for debugging runs (#2653)
* Fix for the MCP tool that gets logs for debugging runs

This was broken when we changed the data on the backend that returns 
log/span data from runs. We changed the data structured and the internal
API that the MCP client uses was failing to parse with the Zod schema

* add changeset

* Revert "add changeset"

This reverts commit 86eca836d5907fa0d0f8ac595d4d5ebade140514.

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-11-11 13:26:07 +00:00
Saadi Myftija 9624465ee2 fix: error handling issue with s2 streams (#2664)
* Fix logger import

*old man yells at auto-import*

* Fix s2 error handling for non-existing streams
2025-11-11 09:44:54 +01:00
Saadi Myftija 42f53b12b7 feat(deployments): stream build server logs (#2663)
* Use read-only project-scoped s2 tokens for streaming deployment logs

* Add http2 to remix polyfills

Needed for using s2 client-side.

* Stream build-server logs in the deployment details page

* Disable 12-hour format in the DateTime component

* Enable collapsing the logs panel

* Auto-collapse logs for succesful/timedout/queued deployments

* Make S2 env vars optional

* Show the logs section only for gh-triggered deployments

* Cache s2 access tokens in redis

* Reset streaming state

* Expose 12h format as a param for the Datetime components
2025-11-10 12:42:40 +01:00
github-actions[bot] 9fdf91a1c4 Release v4.0.6 (#2647)
* chore: Update version for release

* Update lock file

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: myftija <saadi.myftija@gmail.com>
2025-11-03 14:42:02 +01:00
nicktrn 9593a46364 fix(extensions): prevent audiowaveform binary removal (#2643)
* fix(extensions): prevent audiowaveform binary removal

* add changeset
2025-11-03 14:17:38 +01:00
Saadi Myftija 27376df903 fix(cli): show a useful error message when config file is missing (#2650)
* fix(cli): show a useful error message when config file is missing

* Add changeset
2025-10-31 16:34:48 +01:00
Saadi Myftija e0cece72ae docs: deployments with local builds (#2645)
* docs: deployments with local builds

Adds a section to the deployment docs page about the option to deploy using a locally built image.

* Update installation link
2025-10-30 18:10:23 +01:00
Saadi Myftija 2f3f82f3a9 feat(deployments): show local build hint during depot outages (#2646)
* Add an API endpoint to query remote build provider status

* Show local build hint for failed deployments when Depot is down

* Show the local build flag in the help output

* Add changeset

* Fix import

* Fix docs link
2025-10-30 18:09:54 +01:00
Dan 4264bdf429 Added use cases section in the docs (#2641)
* Started adding use cases pages

* Updates

* More improvements

* Improved diagrams

* Added overview page

* More copy + diagram updates

* Improved diagram titles

* Further diagram improvements

* Corrected workflow

* Updated copy

* Typos

* Updated intro file

* Reverted aiRunFilterService.server.ts
2025-10-30 09:21:31 +00:00
nicktrn bf6735bd56 fix(webapp): concurrency override upper bound should be env not org (#2642) 2025-10-29 19:16:42 +00:00
James Ritchie dacd53b906 Fix for showing the incident UI in the side menu (#2638) 2025-10-28 21:13:23 +00:00
github-actions[bot] d4fe71df34 Release v4.0.5 (#2531)
* chore: Update version for release

* Release v4.0.5

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2025-10-28 14:20:20 +00:00
nicktrn ae8e83b2d0 chore(runner): move max duration logic into parent process (#2637)
* chore(runner): move max duration logic into parent process

* chore(rsc): remove type-marker package.json

* add changeset

* chore(core): remove irrelevant test after our changes

* chore(core): clarify we don't care about the timeout promise
2025-10-28 13:49:59 +00:00
nicktrn 2283ca6ad3 fix(webapp): persist concurrency overrides on deploy (#2636)
* fix(webapp): display correct concurrency override base value

* fix(webapp): persist concurrency overrides on deploy

* fix(webapp): use correct override base value type

* fix(webapp): override input is bounded by env concurrency
2025-10-28 11:28:38 +00:00
nicktrn 8fdbbeb02f chore(helm): increase default clickhouse resources (#2635) 2025-10-27 13:45:09 +00:00
Marcus Nerløe 255ea0a4b3 fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler (#2627)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
* fix(supervisor): prevent escalating duplicate reconnections in failedPodHandler

* fix: added catch handler for informer.start() failures

* fix: removed 'errorStack' from error log
2025-10-23 11:13:45 +01:00
Saadi Myftija 7f25e82299 feat(deployments): support local builds in cloud (#2628)
* Enable skipping image push during deployment finalization step

* Add endpoint to generate registry credentials for a deployment

* Add a --force-local-build flag to the deployment command to skip remote build

* Do not show the new flag in the help output

* Add changeset

* Remove registry login logs from onLog, not useful

* Rename var

* Update platform package to the latest version
2025-10-23 10:57:36 +02:00
Eric Allam d90da7abf7 fix(replication): allow disabling of task run payload inserts via env var (#2626) 2025-10-22 11:22:14 +01:00
Matt Aitken 2affe541e8 Dev concurrency limit by env (with optional global limit) (#2625)
* Limit local dev concurrency using the dev environment concurrency limit

Previously it was limited to max of 25, no matter the environment limit

* Have global dev limit
2025-10-22 10:55:05 +01:00
Matt Aitken 3157b657c7 Test page recent payload: use ClickHouse to get the latest run ids (#2614)
The Postgres query to get the latest run ids for the test page was very 
slow when there were a lot of runs and/or versions.

This now uses the standard runs list we use everywhere else.
2025-10-17 17:17:36 +01:00
Matt Aitken 41bdab58d5 Document the dev --analyze command (#2613) 2025-10-17 13:00:30 +01:00
James Ritchie fe3fe01fe8 feat(queues): Override queue concurrency limits from the dashboard or API (#2609)
* feat(queues): add ability to override concurrency limit via API and dashboard

* Updates the modal layout and tweaks copy

* Improves the dropdown menu item

* Popover supports both Button and LinkButton

* Right align the columns and fix the dropdown menu item styles

* Organize imports,

* Fix spinner icon in dropdown menu

* Remove unused props

* Adds a tooltip to the Concurrency override badge

* Fixes console error with popover menu

* typo

* Fixes incorrect className

* Minimal buttons to view runs

---------

Co-authored-by: Eric Allam <eallam@icloud.com>
2025-10-17 12:58:25 +01:00
Matt Aitken 68d0037e60 fix(dev): dotenv issues when getting setup with the repo (#2612)
- The .env.example was missing required Clickhouse values.
- A symlink was needed for .env from the root to the apps/webapp folder
2025-10-17 11:49:40 +01:00
Matt Aitken 885d2d3560 Tags listing now uses ClickHouse (#2576)
* WIP using ClickHouse for the tags filter list

* WIP on tags listing

* Webapp: exclude test files when typechecking

* Tags filtering working with CH

* Remove unused import

* The AI filter should only look at the last past 30d of tags

* Do the text query in ClickHouse

* Deal with encoded characters better

* More encoding fixes

* Fix for wrong items being checked

* Put applied tags back

* Add the env.id to the dependencies array
2025-10-15 12:56:08 +01:00
Eric Allam a445af1b79 feat(otel): allow clickhouse task events to be inserted using async_insert via env vars (#2608) 2025-10-15 10:37:17 +01:00
nicktrn 5781783c74 fix(engine): default to paid placement on billing errors (#2604)
* chore(billing): improve logs to distinguish between failure modes

* fix(engine): default to paid placement on billing errors

* chore(engine): set plan type according to paying field when missing
2025-10-15 10:32:27 +01:00
nicktrn cca10c22d3 chore(supervisor): add machine label (#2603) 2025-10-15 10:31:37 +01:00
Leonardo Kaynan 63b6fc93fa fix(helm): align values-production-example.yaml with values.yaml (#2606)
- Move S3 credentials from secrets.objectStore to s3.auth
- Update external PostgreSQL config to use databaseUrl/directUrl approach
- Add existingSecret support for PostgreSQL with secretKeys
- Add TLS configuration for external Redis
- Add existingSecret support for Redis, ClickHouse and S3
- Add complete external S3 configuration example
- Improve secure credential management documentation

These changes align the production example file with the current values.yaml
structure, making it easier to configure external services with better
support for secret management.
2025-10-14 23:16:58 +01:00
Saadi Myftija aa66462971 docs(build-server): adjust build env vars section (#2605)
Adds a clarification about the env var prefix stripping.
2025-10-14 17:29:42 +02:00
James Ritchie f6461684ad chore(webapp): adds shortcut key for admin area (#2570)
* Allow shortcuts hook to work if undefined

* Conditionally show shortcut button if only 1 result

* LinkButton can accept conditionally shown shortcuts
2025-10-13 13:26:00 +01:00
James Ritchie a6896b411a Adds a link to edit the profile icon from the main menu (#2572) 2025-10-13 13:25:06 +01:00
Saadi Myftija 0cabbdd31f docs: deploying using the github integration (#2598)
* docs: deploying using the github integration

* Add hint in the gh actions docs page

* Remove extra space

* Add a couple of hints to the build config fields
2025-10-13 14:15:48 +02:00
Eric Allam f8977a7b70 chore(db): remove unnecessary FK constraints on TaskRunExecutionSnapshot (#2533) 2025-10-09 14:07:45 +01:00
Saadi Myftija f0643f76f5 feat(build-server): add option to specify pre-build command (#2596)
* feat(build-server): add option to specify pre-build command

Adds an option to specify a pre-build command in the build settings. Can
be useful for projects that need a step before the build, e.g., to
generate a prisma client.

Also, remove the install directory in favor of simplicity. Both
pre-build and install commands are run from the root of the repo. Users
that need to run the commands in a different dir can just prepend to the
command, e.g., `cd apps/web && pnpm run primsa:migrate`

* Fix spelling
2025-10-08 17:25:44 +02:00
Saadi Myftija 416dbcd536 fix(webapp): disable gh-triggered preview deployments if the preview env is disabled (#2595)
* Show hint if preview branches are disabled in the project

* Enable preview deployments only if the preview environemtn is enabled

* Fix prisma reference
2025-10-08 16:59:25 +02:00
Eric Allam 679b41dc7e chore(electric): upgrade server to 1.1.14 (#2590) 2025-10-08 14:33:10 +01:00
Eric Allam be98aecbfd fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors (#2594)
* fix(otel): prevent unpaired unicode surrogate pairs from causing insert errors

* only check parts of the string that are not going to get truncated
remove unnecessary taks
2025-10-08 14:32:35 +01:00
nicktrn 129dc02f2a chore: add deepwiki badge to main readme (#2587)
This means DeepWiki will automatically re-index our repo and keep our page fresh
2025-10-08 13:15:06 +01:00
nicktrn 8917478d3c fix(runner): SIGTERM handling during warm start long poll (#2593) 2025-10-08 12:51:10 +01:00
Leonardo Kaynan f5caa66348 fix(helm): use bitnami legacy repo for minio console (#2592)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* fix(charts): use bitnamilegacy repo for MinIO Object Browser

The Bitnami `minio-object-browser` image has been removed from Docker
Hub. This patch updates the Trigger Helm chart to reference the
`bitnamilegacy/minio-object-browser` repository under the
`s3.console.image` section, ensuring that the legacy MinIO browser pod
can still be deployed without image pull errors.

All other MinIO components continue using the `bitnamilegacy/minio`
image for consistency across Bitnami Legacy dependencies.

* chore(helm): bump Helm chart version to 4.0.4
2025-10-07 22:17:37 +01:00
Eric Allam 64fcc88fa7 fix(otel): spans with entities (like waitpoints) now correctly returned from clickhouse repo (#2591) 2025-10-07 16:55:00 +01:00
nicktrn 23dbe282ed security: upgrade transitive axios dep (CVE-2025-58754) (#2589) 2025-10-07 14:56:16 +01:00
Eric Allam 692316e82a fix(realtime): Upgrade to @electric-sql/client@1.0.14 to prevent cached 409 Conflict errors from breaking realtime updates (#2588) 2025-10-07 14:26:03 +01:00
Saadi Myftija 107f4dc87c fix(deployments): retry transient depot build init failures (#2586)
The Depot build init with `depot.build.v1.BuildService.createBuild` fails surprisingly often due to transient errors, causing the whole deployment to fail. This PR adds a simple retry mechanism with backoff using p-retry. This should improve the failure rate.
2025-10-06 11:33:31 +02:00
Eric Allam b90f3e2173 fix(otel): remove clickhouse event repo feature flag support from v3, now v4 only (#2585) 2025-10-06 09:54:23 +01:00
Eric Allam b3b2553651 fix(otel): propagate the task event store to run descendants (#2583) 2025-10-04 07:28:16 -07:00
Eric Allam cdd1a8838c fix(otel): prevent spans with negative durations (#2582) 2025-10-03 06:50:16 -07:00
Eric Allam 200b7354d0 fix(otel): clickhouse logs/span metrics now exclude partials and debug events (#2581) 2025-10-02 13:51:50 -07:00
Eric Allam eeed38d223 fix(clickhouse): correctly format datetime64(9) input format (#2580) 2025-10-02 11:32:32 -07:00
nicktrn 0ca092651b feat(supervisor): optional custom scheduler (#2579) 2025-10-02 16:22:53 +01:00
Eric Allam 53acdf8ef5 fix(otel): don't pass isDebug when creating postgresql task events (#2578) 2025-10-01 21:57:35 -07:00
Eric Allam 128bc437f6 feat(otel): Add support for storing run spans and log data in Clickhouse (#2567) 2025-10-01 12:41:18 -07:00
James Ritchie 0597691001 Adds 200 and 500 % billing alert options (#2571) 2025-09-30 13:59:44 -07:00
mintlify[bot] dae84a0d29 Update docs/idempotency.mdx (#2575)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2025-09-30 13:50:02 -07:00
nicktrn f72d63aac2 chore(helm): migrate to bitnami legacy registry and add configurable utility images (#2574)
🧭 Helm Chart Release / lint-and-test (push) Has been cancelled
🧭 Helm Chart Release / release (push) Has been cancelled
* chore(docker): use bitnami legacy repo

* chore(helm): use bitnami legacy repo

* Make Helm webapp chart images configurable

Adds configurability for init and token syncer container images through
new values in the Helm chart configuration

* chore(helm): refactor utility image config

* chore(helm): bump chart version to 4.0.3

---------

Co-authored-by: LeoKaynan <leokaynan@hotmail.com>
2025-09-30 16:02:08 +01:00
nicktrn 12cceaa779 feat(helm): support topology spread constraints for webapp (#2560)
* feat(helm): support topology spread constraints

* chore(helm): update topology env var

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore(helm): limit spread constraints to webapp for now

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-30 15:31:04 +01:00
Saadi Myftija ddebe4dce0 feat(webapp): rate limit magic-link login attempts (#2568)
* feat(webapp): rate limit magic-link login attempts

Adds a simple rate limiter to the login with magic link flow. Similar implementation to the MFA rate limits.

* Fix error message

* Add an env var feature flags for login rate limiting

* Use BoolEnv instead of `0`/`1`

* Parse xff properly
2025-09-29 17:28:36 +02:00
Saadi Myftija 09d51c6d24 fix(webapp): add recommended security headers (#2569)
Sets `Referrer-Policy`, `X-Content-Type-Options` and `Permissions-Policy` headers.
Relevant against certain types of attacks.
2025-09-29 16:55:22 +02:00
Saadi Myftija 3ceea774a8 fix(run-engine): waitpoint update misleading error logs (#2566) 2025-09-26 21:16:12 +02:00
Eric Allam 05b6a26c4f fix(run-engine): pass through engine fair dequeue selection strategy options instead of using defaults (#2565) 2025-09-26 17:12:36 +01:00
Eric Allam 558fb11b89 feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status (#2564)
* feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing
feat(run-engine): ability to repair runs in QUEUED, SUSPENDED, and FINISHED execution status

* Handle FINISHED snapshot in the repair
2025-09-26 15:16:10 +01:00
nicktrn 9aedda23a4 fix(run-engine): carryover batchId after PENDING_EXECUTING stalls (#2563) 2025-09-26 14:14:28 +01:00
Eric Allam 743b8dbe0c chore(run-engine): add additional logging around dequeueing and worker queues (#2562) 2025-09-26 11:50:21 +01:00
Eric Allam eb0263e942 feat(server): add two admin endpoints for queue and environment concurrency debugging and repairing (#2559) 2025-09-25 19:33:10 +01:00
Saadi Myftija 7bf579fa50 fix: use higher entropy invite tokens (#2558)
* fix: use higher entropy invite tokens

We currently use CUIDs for invite tokens, which are generated using
a pattern and are not cryptographically secure. This PR switches to
a higher entropy string generated with `nanoid`.

* Dedupe the invite emails in the application
2025-09-25 19:42:59 +02:00
Saadi Myftija 69d52db856 fix(webapp): org scoping issues in plan selection, alerts, pats and usage (#2549)
* fix: org scoping in the select plan flow

Adds proper org scoping in the loader and action in the plans page.

* Fix billing alerts scope

* Fix org usage page scope

* Fix token revoking flow scope check

* Throw error for failed PAT revokes instead of silent failure
2025-09-25 19:17:51 +02:00
Saadi Myftija a3cea1302e fix(webapp): org invite scoping (#2554)
* fix: org invite scoping

Fixes some scoping issues with team invites.

* Fix invite flow changes
2025-09-25 19:17:19 +02:00
Eric Allam a3bdd3c64b chore(run-engine): improve concurrency sweeper logging to get better visibility (#2557) 2025-09-25 16:43:43 +01:00
Saadi Myftija 6d6e98aa11 fix(webapp): project scoping for runs (#2553)
* fix: project scoping for runs

* Apply some 🐰 suggestions
2025-09-25 17:43:13 +02:00
Eric Allam e22c321dd1 fix(engine) truncate errors before storing them on a run and waitpoint output (#2552) 2025-09-25 13:39:51 +01:00
Eric Allam 59df4af1eb chore(engine): add additional logging when we fail to get snapshots since (#2551) 2025-09-25 12:59:02 +02:00
Eric Allam 8863ff05c9 fix(engine): limit the number of snapshots returned when getting latest snapshots since (#2550) 2025-09-25 11:53:35 +01:00
Matt Aitken d10281e655 Additional files docs with legacyDevProcessCwdBehaviour (#2543) 2025-09-24 14:32:02 +01:00
nicktrn 6798d57e72 chore(helm): bump image versions to 4.0.4 (#2537) 2025-09-24 14:31:30 +01:00
Saadi Myftija 480c0d34d3 fix(webapp): toast message issue after gh app installation (#2546)
* fix(webapp): toast message issue after gh app installation

Fixes an issue with displaying toasts messages in the project settings
page. The github callback cookie was interfering with the flash cookie used
for toast messages.

* Do not set a tracking branch in the staging env by default
2025-09-24 15:20:03 +02:00
Saadi Myftija 700a6ea598 feat: enable canceling deployments (#2545)
* Add canceledAt to the deployment db schema

* Expose an api endpoint to cancel deployments

* Show the canceled status description in the dashboard

* Enable canceling deployments from the dashboard

* Show cancelation reason in the deployment details

* Make verifyProjectMembership a function for consistency

* Apply some good 🐰 suggestions
2025-09-24 11:14:29 +02:00
Saadi Myftija cc94d121f2 feat: installing status for deployments (#2544)
* Add installing status to the deployment db schema

* Replace the deployments /start endpoint with /progress

* Show the installing status in the dashboard

* Add installing status to the api schema and cli

* Add changeset
2025-09-24 10:27:43 +02:00
Saadi Myftija 412e80fdde fix(webapp): hide outdated connected repos from deleted installations (#2538) 2025-09-23 13:24:58 +01:00
Saadi Myftija 49728b5a5f feat(api): defer remote build creation for pending deployments (#2536)
Depot builds have short-lived tokens and their TTL is not exposed in the SDK. As queued deployments can stay in the queue for an arbitrary amount of time, deferring the remote build creation helps avoid expired Depot token issues.
2025-09-22 13:01:56 +02:00
Saadi Myftija d45696c000 fix(api): 204 response issue in deployment start endpoint (#2534) 2025-09-19 19:05:18 +02:00
Eric Allam 8313800746 fix(webapp): don't override spans from ancestors unless the span is partial (#2532) 2025-09-19 14:58:53 +01:00
James Ritchie 28f8cee3a4 Fix for errors returned from searching time specific queries (#2525)
* Fix for errors returned from searching time specific queries

* Adds prompt patterns
2025-09-19 13:49:43 +01:00
Eric Allam 87b3603b23 feat(webapp): completing spans server-side no longer write-after-read, improving efficiency and perf (#2530)
* Cancel run events which then propogate cancellation status to span ancestors

* WIP

* convert closing cached run spans to new system

* converted expired complete span event to new method

* move v3 over to new methods

* Convert getDetailedTraceSummary to use the new ancestor override stuff

* remove debug logs

* Don't return UNSPECIFIED task events in getRunEvents

* fix the call site for cancelling run event in v3

* Add changeset

* remove methods
2025-09-19 13:39:48 +01:00
James Ritchie 365adc24a6 Chore(webapp): adds more copy buttons (#2529)
* Pass up asChild

* Include padding when asChild specified

* Adds copy buttons to useful data in Details tab

* Improve view batch tooltip message
2025-09-19 13:02:10 +01:00
Saadi Myftija 7d17730b52 feat(api): handle build server deployment init gracefully for older cli versionsi (#2526)
This PR adapts the deployment initialization endpoint to handle build server deployments with older CLI versions gracefully.

When we introduced automatic deployments via the build server, we slightly changed the deployment flow
mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status
and updated to `BUILDING` once the build server dequeues the build job.
Newer versions of the `deploy` command in the CLI will automatically attach to the existing deployment
and continue with the build process. For older versions, we can't change the command's client-side behavior,
so we need to handle this case here in the initialization endpoint. As we control the env variables which
the git meta is extracted from in the build server, we can use those to pass the existing deployment ID
to this endpoint. This doesn't affect the git meta on the deployment as it is set prior to this step using the
/start endpoint. It's a rather hacky solution, but it will do for now as it enables us to avoid degrading the
build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
once we stop supporting 3.x CLI versions.
2025-09-19 10:40:59 +02:00
Saadi Myftija e4982bfd6d feat(webapp): deployments page live reloading (#2524)
* Fix `current` badge inconsistency in the deployment details page

* Add custom hook for auto revalidation based on an interval and/or focus change

* Use the autoRevalidate hook for live reloading of the deployments page

* Extract autoReloadPollIntervalMs to an env var

* Replace the sse-based autoreload in bulk actions and queues page with the simpler autoRevalidate hook
2025-09-19 10:40:07 +02:00
github-actions[bot] a03783d1a0 Release v4.0.4
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 19s
🚀 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 19s
* chore: Update version for release

* Release v4.0.4

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <eallam@icloud.com>
2025-09-18 23:18:59 +01:00
Eric Allam 0178bdbb00 fix(packages): remove effect from optional peer dependencies (#2527)
Fixes ERROR: Could not resolve "effect"
2025-09-18 23:09:37 +01:00
736 changed files with 102171 additions and 28875 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
fix(cli): update command should preserve existing package.json order
+11
View File
@@ -0,0 +1,11 @@
# Remove AI code slop
Check the diff against main, and remove all AI generated slop introduced in this branch.
This includes:
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
- Casts to any to get around type issues
- Any other style that is inconsistent with the file
Report at the end with only a 1-3 sentence summary of what you changed
+6
View File
@@ -0,0 +1,6 @@
---
description: how to create and apply database migrations
alwaysApply: false
---
Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations.
+14 -3
View File
@@ -13,6 +13,11 @@ APP_ORIGIN=http://localhost:3030
ELECTRIC_ORIGIN=http://localhost:3060
NODE_ENV=development
# Clickhouse
CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
RUN_REPLICATION_ENABLED=1
# Set this to UTC because Node.js uses the system timezone
TZ="UTC"
@@ -29,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
@@ -80,4 +85,10 @@ POSTHOG_PROJECT_KEY=
# These control the server-side internal telemetry
# INTERNAL_OTEL_TRACE_EXPORTER_URL=<URL to send traces to>
# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0,
# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0
# Enable local observability stack (requires `pnpm run docker` to start otel-collector)
# Uncomment these to send metrics to the local Prometheus via OTEL Collector:
# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1
# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics
# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000
+102
View File
@@ -0,0 +1,102 @@
name: 🦋 Changesets PR
on:
push:
branches:
- main
paths:
- "packages/**"
- ".changeset/**"
- "package.json"
- "pnpm-lock.yaml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release-pr:
name: Create Release PR
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
if: github.repository == 'triggerdotdev/trigger.dev'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create release PR
id: changesets
uses: changesets/action@v1
with:
version: pnpm run changeset:version
commit: "chore: release"
title: "chore: release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update PR title with version
if: steps.changesets.outputs.published != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
if [ -n "$PR_NUMBER" ]; then
git fetch origin changeset-release/main
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
fi
update-lockfile:
name: Update lockfile on release PR
runs-on: ubuntu-latest
needs: release-pr
permissions:
contents: write
steps:
- name: Checkout release branch
uses: actions/checkout@v4
with:
ref: changeset-release/main
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
- name: Install and update lockfile
run: pnpm install --no-frozen-lockfile
- name: Commit and push lockfile
run: |
set -e
if git diff --quiet pnpm-lock.yaml; then
echo "No lockfile changes"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add pnpm-lock.yaml
git commit -m "chore: update lockfile for release"
git push origin changeset-release/main
fi
+2 -2
View File
@@ -31,12 +31,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
- name: 📥 Download deps
run: pnpm install --frozen-lockfile --filter trigger.dev...
+1
View File
@@ -1,6 +1,7 @@
name: 🚀 Publish Trigger.dev Docker
on:
workflow_dispatch:
workflow_call:
inputs:
image_tag:
+135 -48
View File
@@ -1,98 +1,185 @@
name: 🦋 Changesets Release
permissions:
contents: write
on:
push:
pull_request:
types: [closed]
branches:
- main
paths-ignore:
- "docs/**"
- "**.md"
- ".github/CODEOWNERS"
- ".github/ISSUE_TEMPLATE/**"
workflow_dispatch:
inputs:
type:
description: "Select release type"
required: true
type: choice
options:
- release
- prerelease
default: "prerelease"
ref:
description: "The ref (branch, tag, or SHA) to checkout and release from"
required: true
type: string
prerelease_tag:
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
required: false
type: string
default: "prerelease"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
release:
name: 🦋 Changesets Release
show-release-summary:
name: 📋 Release Summary
runs-on: ubuntu-latest
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref == 'changeset-release/main'
steps:
- name: Show release summary
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
release:
name: 🚀 Release npm packages
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: write
packages: write
pull-requests: write
if: github.repository == 'triggerdotdev/trigger.dev'
id-token: write
if: |
github.repository == 'triggerdotdev/trigger.dev' &&
(
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
)
outputs:
published: ${{ steps.changesets.outputs.published }}
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
published_package_version: ${{ steps.get_version.outputs.package_version }}
steps:
- name: ⬇️ Checkout repo
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
- name: ⎔ Setup pnpm
- name: Verify ref is on main
if: github.event_name == 'workflow_dispatch'
run: |
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
echo "Error: ref must be an ancestor of main (i.e., already merged)"
exit 1
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: Setup node
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
cache: "pnpm"
- name: 📥 Download deps
# npm v11.5.1 or newer is required for OIDC support
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
- name: Setup npm 11.x for OIDC
run: npm install -g npm@11.6.4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 📀 Generate Prisma Client
- name: Generate Prisma client
run: pnpm run generate
- name: 🏗️ Build
- name: Build
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: 🔎 Type check
- name: Type check
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
- name: 🔐 Setup npm auth
run: |
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
# This action has two responsibilities. The first time the workflow runs
# (initial push to the `main` branch) it will create a new branch and
# then open a PR with the related changes for the new version. After the
# PR is merged, the workflow will run again and this action will build +
# publish to npm.
- name: 🚀 PR / Publish
if: ${{ !env.ACT }}
- name: Publish
id: changesets
uses: changesets/action@v1
with:
version: pnpm run changeset:version
commit: "chore: Update version for release"
title: "chore: Update version for release"
publish: pnpm run changeset:release
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# - name: 🚀 PR / Publish (mock)
# if: ${{ env.ACT }}
# id: changesets
# run: |
# echo "published=true" >> "$GITHUB_OUTPUT"
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
- name: 📦 Get package version
- name: Show package version
if: steps.changesets.outputs.published == 'true'
id: get_version
run: |
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
# this triggers the publish workflow for the docker images
- name: Create and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
set -e
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
prerelease:
name: 🧪 Prerelease
runs-on: ubuntu-latest
environment: npm-publish
permissions:
contents: read
id-token: write
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.inputs.ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0
- name: Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.19.0
cache: "pnpm"
# npm v11.5.1 or newer is required for OIDC support
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
- name: Setup npm 11.x for OIDC
run: npm install -g npm@11.6.4
- name: Download deps
run: pnpm install --frozen-lockfile
- name: Generate Prisma Client
run: pnpm run generate
- name: Snapshot version
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Clean
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
- name: Build
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
- name: Publish prerelease
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -19,12 +19,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
cache: "pnpm"
- name: 📥 Download deps
@@ -35,6 +35,8 @@ jobs:
- name: 🔎 Type check
run: pnpm run typecheck
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: 🔎 Check exports
run: pnpm run check-exports
+4 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -111,12 +111,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+4 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -111,12 +111,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+4 -4
View File
@@ -53,12 +53,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
cache: "pnpm"
# ..to avoid rate limits when pulling images
@@ -119,12 +119,12 @@ jobs:
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 8.15.5
version: 10.23.0
- name: ⎔ Setup node
uses: buildjet/setup-node@v4
with:
node-version: 20.11.1
node-version: 20.19.0
# no cache enabled, we're not installing deps
- name: Download blob reports from GitHub Actions Artifacts
+5 -6
View File
@@ -29,12 +29,10 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env.docker
.env
.env.*
.docker/*.env
.env.local
.env.development.local
.env.test.local
.env.production.local
!.env.example
# turbo
.turbo
@@ -64,4 +62,5 @@ apps/**/public/build
/packages/trigger-sdk/src/package.json
/packages/python/src/package.json
.claude
.mcp.log
.mcp.log
.cursor/debug.log
-5
View File
@@ -1,5 +0,0 @@
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
prefer-workspace-packages=true
update-notifier=false
side-effects-cache=false
+1 -1
View File
@@ -1 +1 @@
v20.11.1
v20.19.0
+2 -1
View File
@@ -6,5 +6,6 @@
"**/node_modules/**": true,
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": false
}
+1 -1
View File
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
See `ai/references/repo.md` for a more complete explanation of the workspaces.
## Development setup
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
3. Start the local services with Docker:
```bash
+2 -2
View File
@@ -15,7 +15,7 @@ branch are tagged into a release periodically.
### Prerequisites
- [Node.js](https://nodejs.org/en) version 20.11.1
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
- [Docker](https://www.docker.com/get-started/)
- [protobuf](https://github.com/protocolbuffers/protobuf)
@@ -36,7 +36,7 @@ branch are tagged into a release periodically.
```
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
5. Install the required packages using pnpm.
```
+2 -1
View File
@@ -4,7 +4,7 @@
### Build and deploy fullymanaged AI agents and workflows
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview)
[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE)
@@ -13,6 +13,7 @@
[![Twitter Follow](https://img.shields.io/twitter/follow/triggerdotdev?style=social)](https://twitter.com/triggerdotdev)
[![Discord](https://img.shields.io/discord/1066956501299777596?logo=discord&logoColor=white&color=7289da)](https://discord.gg/nkqV9xBYWy)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/triggerdotdev/trigger.dev)
[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev)
</div>
+121
View File
@@ -0,0 +1,121 @@
## Creating and applying migrations
We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file:
Edit the `schema.prisma` file to add or modify the schema.
Create a new migration file but don't apply it yet:
```bash
cd internal-packages/database
pnpm run db:migrate:dev:create --name "add_new_column_to_table"
```
The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
All the following lines should be removed:
```sql
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique";
-- AlterTable
ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique";
-- AlterTable
ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique";
-- AlterTable
ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_WaitpointRunConnections_AB_unique";
-- AlterTable
ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "public"."_completedWaitpoints_AB_unique";
-- CreateIndex
CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC);
-- CreateIndex
CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC);
```
Leaving only this:
```sql
-- AlterEnum
ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED';
-- AlterTable
ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB;
```
After editing the migration file, apply the migration:
```bash
cd internal-packages/database
pnpm run db:migrate:deploy && pnpm run generate
```
+1 -1
View File
@@ -1,6 +1,6 @@
## Repo Overview
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
## Apps
+3 -3
View File
@@ -5,7 +5,7 @@ WORKDIR /app
FROM node-22-alpine AS pruner
COPY --chown=node:node . .
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
FROM node-22-alpine AS base
@@ -16,7 +16,7 @@ COPY --from=pruner --chown=node:node /app/out/json/ .
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN corepack enable && corepack prepare --activate
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
FROM base AS deps-fetcher
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
RUN pnpm run generate && \
pnpm run --filter supervisor... build&& \
pnpm deploy --filter=supervisor --prod /prod/supervisor
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
FROM base AS runner
+2 -1
View File
@@ -57,7 +57,7 @@ const Env = z.object({
RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
// Docker settings
DOCKER_API_VERSION: z.string().default("v1.41"),
DOCKER_API_VERSION: z.string().optional(),
DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64
DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true),
DOCKER_REGISTRY_USERNAME: z.string().optional(),
@@ -90,6 +90,7 @@ const Env = z.object({
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
// Placement tags settings
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
+8
View File
@@ -244,6 +244,12 @@ class ManagedSupervisor {
}
try {
if (!message.deployment.friendlyId) {
// mostly a type guard, deployments always exists for deployed environments
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
throw new Error("Deployment is missing");
}
await this.workloadManager.create({
dequeuedAt: message.dequeuedAt,
envId: message.environment.id,
@@ -252,6 +258,8 @@ class ManagedSupervisor {
machine: message.run.machine,
orgId: message.organization.id,
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
@@ -25,6 +25,7 @@ export class FailedPodHandler {
private readonly informer: Informer<V1Pod>;
private readonly reconnectIntervalMs: number;
private reconnecting = false;
// Metrics
private readonly register: Registry;
@@ -250,21 +251,48 @@ export class FailedPodHandler {
}
private makeOnError(informerName: string) {
return () => this.onError(informerName);
return (err?: unknown) => this.onError(informerName, err);
}
private async onError(informerName: string) {
private async onError(informerName: string, err?: unknown) {
if (!this.isRunning) {
this.logger.warn("onError: informer not running");
return;
}
this.logger.error("error event fired", { informerName });
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Guard against multiple simultaneous reconnections
if (this.reconnecting) {
this.logger.debug("onError: reconnection already in progress, skipping", {
informerName,
});
return;
}
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
this.reconnecting = true;
try {
const error = err instanceof Error ? err : undefined;
this.logger.error("error event fired", {
informerName,
error: error?.message,
errorType: error?.name,
});
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
// Reconnect on errors
await setTimeout(this.reconnectIntervalMs);
await this.informer.start();
} catch (handlerError) {
const error = handlerError instanceof Error ? handlerError : undefined;
this.logger.error("onError: reconnection attempt failed", {
informerName,
error: error?.message,
errorType: error?.name,
errorStack: error?.stack,
});
} finally {
this.reconnecting = false;
}
}
private makeOnConnect(informerName: string) {
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
`TRIGGER_ENV_ID=${opts.envId}`,
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
name: "TRIGGER_ENV_ID",
value: opts.envId,
},
{
name: "TRIGGER_DEPLOYMENT_ID",
value: opts.deploymentFriendlyId,
},
{
name: "TRIGGER_DEPLOYMENT_VERSION",
value: opts.deploymentVersion,
},
{
name: "TRIGGER_SNAPSHOT_ID",
value: opts.snapshotFriendlyId,
@@ -274,6 +282,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
restartPolicy: "Never",
automountServiceAccountToken: false,
imagePullSecrets: this.getImagePullSecrets(),
...(env.KUBERNETES_SCHEDULER_NAME
? {
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
}
: {}),
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
? {
nodeSelector: {
@@ -302,6 +315,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
envtype: this.#envTypeToLabelValue(opts.envType),
org: opts.orgId,
project: opts.projectId,
machine: opts.machine.name,
};
}
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
envType: EnvironmentType;
orgId: string;
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
+1
View File
@@ -0,0 +1 @@
../../.env
+2 -1
View File
@@ -9,7 +9,8 @@ node_modules
/app/styles/tailwind.css
# Ensure the .env symlink is not removed by accident
!.env
# Storybook build outputs
build-storybook.log
@@ -0,0 +1,13 @@
export function ConcurrencyIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
</svg>
);
}
@@ -0,0 +1,30 @@
export function ListBulletIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 5H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 12H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 19H20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="4" cy="5" r="1" fill="currentColor" />
<circle cx="4" cy="12" r="1" fill="currentColor" />
<circle cx="4" cy="19" r="1" fill="currentColor" />
</svg>
);
}
@@ -0,0 +1,27 @@
export function MoveToBottomIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 15L12 3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21L21 21"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 12.5L12 17L16.5 12.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,20 @@
export function SnakedArrowIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 5H16C17.6569 5 19 6.34315 19 8L19 8.5C19 10.1569 17.6569 11.5 16 11.5H8C6.34314 11.5 5 12.8431 5 14.5L5 15C4.99999 16.6569 6.34314 18 8 18H18.634"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 21L19 18L16 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,10 @@
export function StreamsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19C3 19 5.01155 17 8 17C10.9885 17 13 18.9973 16 18.9973C19 18.9973 21 17 21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M3 13.0001C3 13.0001 5.01155 11 8 11C10.9885 11 13 13 16 13C19 13 21 11.0001 21 11.0001" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M3 7C3 7 5.01155 5 8 5C10.9885 5 13 6.9973 16 6.9973C19 6.9973 21 5 21 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
);
}
@@ -0,0 +1,22 @@
export function GoogleLogo({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
fill="#4285F4"
/>
<path
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
fill="#34A853"
/>
<path
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
fill="#FBBC02"
/>
<path
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
fill="#EA4335"
/>
</svg>
);
}
+148 -132
View File
@@ -52,6 +52,13 @@ import {
} from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { V4Badge } from "./V4Badge";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "./primitives/ClientTabs";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export function HasNoTasksDev() {
return (
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
return <DeploymentOnboardingSteps />;
}
export function SchedulesNoPossibleTaskPanel() {
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploy for the first time"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
);
return <DeploymentOnboardingSteps />;
}
export function DeploymentsNoneDev() {
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-deployments"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
<>
<div className="mb-6 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<StepNumber stepNumber="" title="Switch to a deployed environment" />
<StepContentContainer className="mb-4 flex flex-col gap-4">
<Paragraph>
This is the Development environment. When you're ready to deploy your tasks, switch to a
different environment.
</Paragraph>
<Paragraph spacing variant="small">
There are several ways to deploy your tasks. You can use the CLI or a Continuous
Integration service like GitHub Actions. Make sure you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
/>
</StepContentContainer>
</>
);
}
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
</div>
);
}
function DeploymentOnboardingSteps() {
const environment = useEnvironment();
const organization = useOrganization();
const project = useProject();
return (
<PackageManagerProvider>
<div className="mb-2 flex items-center justify-between border-b">
<div className="mb-2 flex items-center gap-2">
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
</div>
<div className="flex items-center">
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={BookOpenIcon}
leadingIconClassName="text-blue-500"
to={docsPath("deployment/overview")}
/>
}
content="Deploy docs"
/>
<SimpleTooltip
button={
<LinkButton
variant="small-menu-item"
LeadingIcon={QuestionMarkCircleIcon}
leadingIconClassName="text-blue-500"
to={docsPath("troubleshooting#deployment")}
/>
}
content="Troubleshooting docs"
/>
<AskAI />
</div>
</div>
<ClientTabs defaultValue="github">
<ClientTabsList variant="segmented" className="mb-6">
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
GitHub
</ClientTabsTrigger>
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
Manual
</ClientTabsTrigger>
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
GitHub Actions
</ClientTabsTrigger>
</ClientTabsList>
<ClientTabsContent value={"github"}>
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
<StepContentContainer>
<Paragraph spacing>
Deploy automatically with every push. Read the{" "}
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
</Paragraph>
<div className="w-fit">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"cli"}>
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
<StepContentContainer>
<Paragraph spacing>
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
</Paragraph>
<TriggerDeployStep environment={environment} />
</StepContentContainer>
</ClientTabsContent>
<ClientTabsContent value={"github-actions"}>
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
<StepContentContainer>
<Paragraph spacing>
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
get started.
</Paragraph>
</StepContentContainer>
</ClientTabsContent>
</ClientTabs>
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
</StepContentContainer>
</PackageManagerProvider>
);
}
+23 -3
View File
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
import { type ReactNode, useEffect, useState } from "react";
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { Button } from "./primitives/Buttons";
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
type FeedbackProps = {
button: ReactNode;
defaultValue?: FeedbackType;
onOpenChange?: (open: boolean) => void;
};
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
const [open, setOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const lastSubmission = useActionData();
const navigation = useNavigation();
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
}
}, [navigation, form]);
// Handle URL param functionality
useEffect(() => {
const open = searchParams.get("feedbackPanel");
if (open) {
setType(open as FeedbackType);
setOpen(true);
// Clone instead of mutating in place
const next = new URLSearchParams(searchParams);
next.delete("feedbackPanel");
setSearchParams(next);
}
}, [searchParams]);
const handleOpenChange = (value: boolean) => {
setOpen(value);
onOpenChange?.(value);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent>
<DialogHeader>Contact us</DialogHeader>
+4
View File
@@ -134,6 +134,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Jump to adjacent">
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Expand all">
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
</Shortcut>
@@ -22,6 +22,7 @@ export function UserAvatar({
className={cn("aspect-square rounded-full p-[7%]")}
src={avatarUrl}
alt={name ?? "User"}
referrerPolicy="no-referrer"
/>
</div>
) : (
@@ -13,16 +13,18 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
UsersIcon,
} from "@heroicons/react/20/solid";
import { useNavigation } from "@remix-run/react";
import { Link, useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
@@ -42,6 +44,7 @@ import {
accountPath,
adminPath,
branchesPath,
concurrencyPath,
logoutPath,
newOrganizationPath,
newProjectPath,
@@ -121,6 +124,7 @@ export function SideMenu({
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
useEffect(() => {
const handleScroll = () => {
@@ -312,6 +316,15 @@ export function SideMenu({
data-action="preview-branches"
badge={<V4Badge />}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-amber-500"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
@@ -397,9 +410,15 @@ function ProjectSelector({
>
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
<div className="flex items-center gap-2.5">
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
<Link
to={organizationSettingsPath(organization)}
className="group relative box-content size-10 overflow-clip rounded-sm bg-charcoal-800"
>
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
</div>
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
<PencilSquareIcon className="size-5 text-text-bright" />
</div>
</Link>
<div className="space-y-0.5">
<Paragraph variant="small/bright">{organization.title}</Paragraph>
<div className="flex items-baseline gap-2">
@@ -276,7 +276,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
{tooltip} {shortcut && renderShortcutKey()}
</TooltipContent>
</Tooltip>
@@ -298,19 +298,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled,
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: (e) => {
if (innerRef.current) {
innerRef.current.click();
e.preventDefault();
e.stopPropagation();
}
},
disabled: disabled || !props.shortcut,
});
return (
<button
@@ -333,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<
LinkProps,
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({
to,
onClick,
@@ -342,19 +340,20 @@ export const LinkButton = ({
onMouseLeave,
download,
disabled = false,
replace,
...props
}: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled: disabled || !props.shortcut,
});
if (disabled) {
return (
@@ -374,7 +373,7 @@ export const LinkButton = ({
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -389,7 +388,8 @@ export const LinkButton = ({
<Link
to={to}
ref={innerRef}
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
replace={replace}
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
@@ -410,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
return (
<NavLink
to={to}
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
target={target}
>
{({ isActive, isPending }) => (
@@ -1,41 +1,185 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "~/utils/cn";
import { motion } from "framer-motion";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import { cn } from "~/utils/cn";
import { type Variants } from "./Tabs";
type ClientTabsContextValue = {
value?: string;
};
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
function useClientTabsContext() {
return React.useContext(ClientTabsContext);
}
const ClientTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
React.useEffect(() => {
if (valueProp !== undefined) {
setValue(valueProp);
}
}, [valueProp]);
const handleValueChange = React.useCallback(
(nextValue: string) => {
if (valueProp === undefined) {
setValue(nextValue);
}
onValueChange?.(nextValue);
},
[onValueChange, valueProp]
);
const controlledProps =
valueProp !== undefined
? { value: valueProp }
: defaultValue !== undefined
? { defaultValue }
: {};
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
return (
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
onValueChange={handleValueChange}
{...controlledProps}
{...props}
/>
</ClientTabsContext.Provider>
);
});
ClientTabs.displayName = TabsPrimitive.Root.displayName;
const ClientTabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex items-center justify-center transition duration-100", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
variant?: Variants;
}
>(({ className, variant = "pipe-divider", ...props }, ref) => {
const variantClassName = (() => {
switch (variant) {
case "segmented":
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
case "underline":
return "flex gap-x-6 border-b border-grid-bright";
default:
return "inline-flex items-center justify-center transition duration-100";
}
})();
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
});
ClientTabsList.displayName = TabsPrimitive.List.displayName;
const ClientTabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
variant?: Variants;
layoutId?: string;
}
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
const context = useClientTabsContext();
const activeValue = context?.value;
const isActive = activeValue === props.value;
if (variant === "segmented") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
className
)}
{...props}
>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
isActive
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{isActive ? (
layoutId ? (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
) : (
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
)
) : null}
</TabsPrimitive.Trigger>
);
}
if (variant === "underline") {
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{layoutId ? (
isActive ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)
) : isActive ? (
<div className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
}
return (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
{children}
</TabsPrimitive.Trigger>
);
});
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const ClientTabsContent = React.forwardRef<
@@ -61,39 +205,7 @@ export type TabsProps = {
currentValue: string;
className?: string;
layoutId: string;
variant?: Variants;
};
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
return (
<TabsPrimitive.List
className={cn(`flex flex-row gap-x-6 border-b border-charcoal-700`, className)}
>
{tabs.map((tab, index) => {
const isActive = currentValue === tab.value;
return (
<TabsPrimitive.Trigger
key={tab.value}
value={tab.value}
className={cn(`group flex flex-col items-center`, className)}
>
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-indigo-500" : "text-charcoal-200"
)}
>
{tab.label}
</span>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
</TabsPrimitive.Trigger>
);
})}
</TabsPrimitive.List>
);
}
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
@@ -3,55 +3,95 @@ import { useState } from "react";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
export function CopyableText({
value,
copyValue,
className,
asChild,
variant,
}: {
value: string;
copyValue?: string;
className?: string;
asChild?: boolean;
variant?: "icon-right" | "text-below";
}) {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(copyValue ?? value);
return (
<span
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
const resolvedVariant = variant ?? "icon-right";
if (resolvedVariant === "icon-right") {
return (
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
className={cn("group relative inline-flex h-6 items-center", className)}
onMouseLeave={() => setIsHovered(false)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
/>
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
<span
onClick={copy}
onMouseDown={(e) => e.stopPropagation()}
className={cn(
"absolute -right-6 top-0 z-10 size-6 font-sans",
isHovered ? "flex" : "hidden"
)}
>
<SimpleTooltip
button={
<span
className={cn(
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
asChild && "p-1",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
className="font-sans"
disableHoverableContent
asChild={asChild}
/>
</span>
</span>
</span>
);
);
}
if (resolvedVariant === "text-below") {
return (
<SimpleTooltip
button={
<Button
variant="minimal/small"
onClick={(e) => {
e.stopPropagation();
copy();
}}
className={cn(
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
className
)}
>
<span>{value}</span>
</Button>
}
content={copied ? "Copied" : "Click to copy"}
className="font-sans px-2 py-1"
disableHoverableContent
open={isHovered || copied}
onOpenChange={setIsHovered}
/>
);
}
return null;
}
@@ -13,7 +13,9 @@ type DateTimeProps = {
includeTime?: boolean;
showTimezone?: boolean;
showTooltip?: boolean;
hideDate?: boolean;
previousDate?: Date | string | null; // Add optional previous date for comparison
hour12?: boolean;
};
export const DateTime = ({
@@ -23,6 +25,7 @@ export const DateTime = ({
includeTime = true,
showTimezone = false,
showTooltip = true,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -50,7 +53,8 @@ export const DateTime = ({
timeZone ?? localTimeZone,
locales,
includeSeconds,
includeTime
includeTime,
hour12
).replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</Fragment>
@@ -66,7 +70,8 @@ export function formatDateTime(
timeZone: string,
locales: string[],
includeSeconds: boolean,
includeTime: boolean
includeTime: boolean,
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
year: "numeric",
@@ -76,6 +81,7 @@ export function formatDateTime(
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
hour12,
}).format(date);
}
@@ -122,7 +128,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
@@ -132,8 +138,8 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
: null;
// Initial formatted values
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
// State for the formatted time
const [formattedDateTime, setFormattedDateTime] = useState<string>(
@@ -150,10 +156,10 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
// Format with appropriate function
setFormattedDateTime(
showDatePart
? formatSmartDateTime(realDate, userTimeZone, locales)
: formatTimeOnly(realDate, userTimeZone, locales)
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
);
}, [locales, realDate, realPrevDate]);
}, [locales, realDate, realPrevDate, hour12]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
@@ -168,7 +174,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
// Format with date and time
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
return new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -178,18 +184,20 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
// Format time only
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
return new Intl.DateTimeFormat(locales, {
hour: "numeric",
hour: "2-digit",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
@@ -198,6 +206,8 @@ export const DateTimeAccurate = ({
timeZone = "UTC",
previousDate = null,
showTooltip = true,
hideDate = false,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
@@ -214,11 +224,13 @@ export const DateTimeAccurate = ({
}, []);
// Smart formatting based on whether date changed
const formattedDateTime = realPrevDate
const formattedDateTime = hideDate
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, localTimeZone, locales)
: formatDateTimeAccurate(realDate, localTimeZone, locales)
: formatDateTimeAccurate(realDate, localTimeZone, locales);
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
if (!showTooltip)
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
@@ -241,7 +253,7 @@ export const DateTimeAccurate = ({
);
};
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -251,26 +263,27 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
}
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
}, [locales, realDate]);
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
}, [locales, realDate, hour12]);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
@@ -278,6 +291,7 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
@@ -44,6 +44,24 @@ const variants = {
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
"outline/large": {
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550",
input: "px-2 rounded text-sm",
iconSize: "size-4 ml-1",
accessory: "pr-1",
},
"outline/medium": {
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550",
input: "px-1 rounded text-sm",
iconSize: "size-4 ml-0.5",
accessory: "pr-1",
},
"outline/small": {
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550",
input: "px-1 rounded text-xs",
iconSize: "size-3 ml-0.5",
accessory: "pr-0.5",
},
};
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
@@ -0,0 +1,220 @@
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import { type ChangeEvent, useRef } from "react";
import { cn } from "~/utils/cn";
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
step?: number;
min?: number;
max?: number;
round?: boolean;
controlSize?: "base" | "large";
};
export function InputNumberStepper({
value,
onChange,
step = 50,
min,
max,
round = true,
controlSize = "base",
name,
id,
disabled = false,
readOnly = false,
className,
placeholder = "Type a number",
...props
}: InputNumberStepperProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleStepUp = () => {
if (!inputRef.current || disabled) return;
// If rounding is enabled, ensure we start from a rounded base before stepping
if (round) {
// If field is empty, treat as 0 (or min if provided) before stepping up
if (inputRef.current.value === "") {
inputRef.current.value = String(min ?? 0);
} else {
commitRoundedFromInput();
}
}
inputRef.current.stepUp();
const event = new Event("change", { bubbles: true });
inputRef.current.dispatchEvent(event);
};
const handleStepDown = () => {
if (!inputRef.current || disabled) return;
// If rounding is enabled, ensure we start from a rounded base before stepping
if (round) {
// If field is empty, treat as 0 (or min if provided) before stepping down
if (inputRef.current.value === "") {
inputRef.current.value = String(min ?? 0);
} else {
commitRoundedFromInput();
}
}
inputRef.current.stepDown();
const event = new Event("change", { bubbles: true });
inputRef.current.dispatchEvent(event);
};
const numericValue = value === "" ? NaN : (value as number);
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
function clamp(val: number): number {
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
let next = val;
if (min !== undefined) next = Math.max(min, next);
if (max !== undefined) next = Math.min(max, next);
return next;
}
function roundToStep(val: number): number {
if (step <= 0) return val;
const base = min ?? 0;
const shifted = val - base;
const quotient = shifted / step;
const floored = Math.floor(quotient);
const ceiled = Math.ceil(quotient);
const down = base + floored * step;
const up = base + ceiled * step;
const distDown = Math.abs(val - down);
const distUp = Math.abs(up - val);
return distUp < distDown ? up : down;
}
function commitRoundedFromInput() {
if (!inputRef.current || disabled || readOnly) return;
const el = inputRef.current;
const raw = el.value;
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
const numeric = Number(raw);
if (Number.isNaN(numeric)) return; // ignore non-numeric
const rounded = clamp(roundToStep(numeric));
if (String(rounded) === String(value)) return;
// Update the real input's value for immediate UI feedback
el.value = String(rounded);
// Invoke consumer onChange with the real element as target/currentTarget
onChange?.({
target: el,
currentTarget: el,
} as unknown as ChangeEvent<HTMLInputElement>);
}
const sizeStyles = {
base: {
container: "h-9",
input: "text-sm px-3",
button: "size-6",
icon: "size-3.5",
gap: "gap-1 pr-1.5",
},
large: {
container: "h-11 rounded-md",
input: "text-base px-3.5",
button: "size-8",
icon: "size-5",
gap: "gap-[0.3125rem] pr-[0.3125rem]",
},
} as const;
const size = sizeStyles[controlSize];
return (
<div
className={cn(
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
size.container,
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
disabled && "cursor-not-allowed opacity-50",
className
)}
>
<input
ref={inputRef}
type="number"
id={id}
name={name}
value={value}
placeholder={placeholder}
onChange={(e) => {
// Allow empty string to pass through so user can clear the field
if (e.currentTarget.value === "") {
// reflect emptiness in the input and notify consumer as empty
if (inputRef.current) inputRef.current.value = "";
onChange?.({
target: e.currentTarget,
currentTarget: e.currentTarget,
} as ChangeEvent<HTMLInputElement>);
return;
}
onChange?.(e);
}}
onBlur={(e) => {
// If blur is caused by clicking our step buttons, we prevent pointerdown
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
if (round) commitRoundedFromInput();
}}
onKeyDown={(e) => {
if (e.key === "Enter" && round) {
e.preventDefault();
commitRoundedFromInput();
}
}}
step={step}
min={min}
max={max}
disabled={disabled}
readOnly={readOnly}
className={cn(
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
size.input,
// Hide number input arrows
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
)}
{...props}
/>
<div className={cn("flex items-center", size.gap)}>
<button
type="button"
onClick={handleStepDown}
onPointerDown={(e) => e.preventDefault()}
disabled={disabled || isMinDisabled}
aria-label={`Decrease by ${step}`}
className={cn(
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
size.button,
"hover:border-error/50 hover:bg-error/30",
"disabled:cursor-not-allowed disabled:opacity-40",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
)}
>
<MinusIcon className={cn("text-error", size.icon)} />
</button>
<button
type="button"
onClick={handleStepUp}
onPointerDown={(e) => e.preventDefault()}
disabled={disabled || isMaxDisabled}
aria-label={`Increase by ${step}`}
className={cn(
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
size.button,
"hover:border-success/40 hover:bg-success/20",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
)}
>
<PlusIcon className={cn("text-success", size.icon)} />
</button>
</div>
</div>
);
}
@@ -17,6 +17,10 @@ const paragraphVariants = {
text: "font-sans text-sm font-normal text-text-bright",
spacing: "mb-2",
},
"small/dimmed": {
text: "font-sans text-sm font-normal text-text-dimmed",
spacing: "mb-2",
},
"extra-small": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
@@ -25,6 +29,14 @@ const paragraphVariants = {
text: "font-sans text-xs font-normal text-text-bright",
spacing: "mb-1.5",
},
"extra-small/dimmed": {
text: "font-sans text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/dimmed/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
},
"extra-small/mono": {
text: "font-mono text-xs font-normal text-text-dimmed",
spacing: "mb-1.5",
@@ -5,9 +5,10 @@ import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { Link } from "@remix-run/react";
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
import { type ButtonContentPropsType, Button, ButtonContent } from "./Buttons";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
import { type RenderIcon } from "./Icon";
@@ -52,42 +53,78 @@ function PopoverSectionHeader({
);
}
function PopoverMenuItem({
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
}: {
to: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
}) {
return (
<LinkButton
to={to}
variant={variant.variant}
LeadingIcon={icon}
leadingIconClassName={leadingIconClassName}
fullWidth
textAlignLeft
TrailingIcon={isSelected ? CheckIcon : undefined}
className={cn(
const PopoverMenuItem = React.forwardRef<
HTMLButtonElement | HTMLAnchorElement,
{
to?: string;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
leadingIconClassName?: string;
className?: string;
onClick?: React.MouseEventHandler;
disabled?: boolean;
}
>(
(
{
to,
icon,
title,
isSelected,
variant = { variant: "small-menu-item" },
leadingIconClassName,
className,
onClick,
disabled,
},
ref
) => {
const contentProps = {
variant: variant.variant,
LeadingIcon: icon,
leadingIconClassName,
fullWidth: true,
textAlignLeft: true,
TrailingIcon: isSelected ? CheckIcon : undefined,
className: cn(
"group-hover:bg-charcoal-700",
isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined,
className
)}
>
{title}
</LinkButton>
);
}
),
} as const;
if (to) {
return (
<Link
to={to}
ref={ref as React.Ref<HTMLAnchorElement>}
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
onClick={onClick as any}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</Link>
);
}
return (
<button
type="button"
ref={ref as React.Ref<HTMLButtonElement>}
onClick={onClick}
disabled={disabled}
className={cn(
"group/button outline-none focus-custom",
contentProps.fullWidth ? "w-full" : ""
)}
>
<ButtonContent {...contentProps}>{title}</ButtonContent>
</button>
);
}
);
PopoverMenuItem.displayName = "PopoverMenuItem";
function PopoverCustomTrigger({
isOpen,
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const medium =
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
+112 -18
View File
@@ -1,10 +1,12 @@
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
export type TabsProps = {
tabs: {
label: string;
@@ -12,13 +14,14 @@ export type TabsProps = {
}[];
className?: string;
layoutId: string;
variant?: Variants;
};
export function Tabs({ tabs, className, layoutId }: TabsProps) {
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
return (
<TabContainer className={className}>
<TabContainer className={className} variant={variant}>
{tabs.map((tab, index) => (
<TabLink key={index} to={tab.to} layoutId={layoutId}>
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
{tab.label}
</TabLink>
))}
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
);
}
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
{children}
</div>
);
export function TabContainer({
children,
className,
variant = "underline",
}: {
children: ReactNode;
className?: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<div
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
>
{children}
</div>
);
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
);
}
return <div className={cn(`flex`, className)}>{children}</div>;
}
export function TabLink({
to,
children,
layoutId,
variant = "underline",
}: {
to: string;
children: ReactNode;
layoutId: string;
variant?: Variants;
}) {
if (variant === "segmented") {
return (
<NavLink
to={to}
className="group relative flex h-full grow items-center justify-center focus-custom"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
<span
className={cn(
"text-sm transition duration-200",
active
? "text-text-bright"
: "text-text-dimmed transition group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active && (
<motion.div
layoutId={layoutId}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
/>
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
to={to}
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
end
>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<span
className={cn(
"text-sm transition duration-200",
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
)}
>
{children}
</span>
);
}}
</NavLink>
);
}
// underline variant (default)
return (
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
{({ isActive, isPending }) => {
@@ -51,13 +138,19 @@ export function TabLink({
<span
className={cn(
"text-sm transition duration-200",
isActive || isPending ? "text-text-bright" : "text-text-bright"
isActive || isPending
? "text-text-bright"
: "text-text-dimmed hover:text-text-bright"
)}
>
{children}
</span>
{isActive || isPending ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
@@ -106,17 +199,18 @@ export function TabButton({
<>
<div className="flex items-center gap-1">
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-bright"
)}
className={"text-sm transition duration-200 text-text-bright"}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
</div>
{isActive ? (
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
/>
) : (
<div className="mt-1 h-0.5 w-full bg-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
)}
+85 -13
View File
@@ -1,12 +1,15 @@
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { Toaster, toast } from "sonner";
import { useTypedLoaderData } from "remix-typedjson";
import { loader } from "~/root";
import { useSearchParams } from "@remix-run/react";
import { useEffect } from "react";
import { Paragraph } from "./Paragraph";
import { useTypedLoaderData } from "remix-typedjson";
import { Toaster, toast } from "sonner";
import { type ToastMessageAction } from "~/models/message.server";
import { type loader } from "~/root";
import { cn } from "~/utils/cn";
import { Button, LinkButton } from "./Buttons";
import { Header2 } from "./Headers";
import { Paragraph } from "./Paragraph";
const defaultToastDuration = 5000;
const permanentToastDuration = 60 * 60 * 24 * 1000;
@@ -19,9 +22,22 @@ export function Toast() {
}
const { message, type, options } = toastMessage;
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
});
const ephemeral = options.action ? false : options.ephemeral;
toast.custom(
(t) => (
<ToastUI
variant={type}
message={message}
t={t as string}
title={options.title}
action={options.action}
/>
),
{
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
}
);
}, [toastMessage]);
return <Toaster />;
@@ -32,11 +48,15 @@ export function ToastUI({
message,
t,
toastWidth = 356, // Default width, matches what sonner provides by default
title,
action,
}: {
variant: "error" | "success";
message: string;
t: string;
toastWidth?: string | number;
title?: string;
action?: ToastMessageAction;
}) {
return (
<div
@@ -51,13 +71,19 @@ export function ToastUI({
>
<div className="flex w-full items-start gap-2 rounded-lg p-3">
{variant === "success" ? (
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
) : (
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
)}
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
<div className="flex flex-col">
{title && <Header2 className="pt-0">{title}</Header2>}
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
{message}
</Paragraph>
<Action action={action} toastId={t} className="my-2" />
</div>
<button
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
onClick={() => toast.dismiss(t)}
>
<XMarkIcon className="size-4" />
@@ -66,3 +92,49 @@ export function ToastUI({
</div>
);
}
function Action({
action,
toastId,
className,
}: {
action?: ToastMessageAction;
toastId: string;
className?: string;
}) {
const [_, setSearchParams] = useSearchParams();
if (!action) return null;
switch (action.action.type) {
case "link": {
return (
<LinkButton
className={className}
variant={action.variant ?? "secondary/small"}
to={action.action.path}
>
{action.label}
</LinkButton>
);
}
case "help": {
const feedbackType = action.action.feedbackType;
return (
<Button
className={className}
variant={action.variant ?? "secondary/small"}
LeadingIcon={EnvelopeIcon}
onClick={() => {
setSearchParams({
feedbackPanel: feedbackType,
});
toast.dismiss(toastId);
}}
>
{action.label}
</Button>
);
}
}
}
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
const variantClasses = {
basic:
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
};
type Variant = keyof typeof variantClasses;
@@ -64,6 +64,8 @@ function SimpleTooltip({
buttonStyle,
asChild = false,
sideOffset,
open,
onOpenChange,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -76,10 +78,12 @@ function SimpleTooltip({
buttonStyle?: React.CSSProperties;
asChild?: boolean;
sideOffset?: number;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<Tooltip open={open} onOpenChange={onOpenChange}>
<TooltipTrigger
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
}
case "Left":
case "ArrowLeft": {
if (e.metaKey) {
return;
}
e.preventDefault();
const selected = selectedIdFromState(state.nodes);
@@ -39,14 +39,14 @@ export function concreteStateFromInput({
}
const nodes = concreteStateFromPartialState(tree, state);
return {
return applyFilterToState({
tree,
nodes,
changes: { selectedId },
filter,
filteredNodes: nodes,
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
};
visibleNodeIds: [],
});
}
export function concreteStateFromPartialState<TData>(
@@ -1,17 +1,27 @@
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/20/solid";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { BatchTaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allBatchStatuses = ["PENDING", "COMPLETED", "ABORTED"] as const satisfies Readonly<
Array<BatchTaskRunStatus>
>;
export const allBatchStatuses = [
"PROCESSING",
"PENDING",
"COMPLETED",
"PARTIAL_FAILED",
"ABORTED",
] as const satisfies Readonly<Array<BatchTaskRunStatus>>;
const descriptions: Record<BatchTaskRunStatus, string> = {
PROCESSING: "The batch is being processed and runs are being created.",
PENDING: "The batch has child runs that have not yet completed.",
COMPLETED: "All the batch child runs have finished.",
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
PARTIAL_FAILED: "Some runs failed to be created. Successfully created runs are still executing.",
ABORTED: "The batch was aborted because child tasks could not be triggered.",
};
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
@@ -47,10 +57,14 @@ export function BatchStatusIcon({
className: string;
}) {
switch (status) {
case "PROCESSING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "PENDING":
return <Spinner className={cn(batchStatusColor(status), className)} />;
case "COMPLETED":
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
case "PARTIAL_FAILED":
return <ExclamationTriangleIcon className={cn(batchStatusColor(status), className)} />;
case "ABORTED":
return <XCircleIcon className={cn(batchStatusColor(status), className)} />;
default: {
@@ -61,10 +75,14 @@ export function BatchStatusIcon({
export function batchStatusColor(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "text-blue-500";
case "PENDING":
return "text-pending";
case "COMPLETED":
return "text-success";
case "PARTIAL_FAILED":
return "text-warning";
case "ABORTED":
return "text-error";
default: {
@@ -75,10 +93,14 @@ export function batchStatusColor(status: BatchTaskRunStatus): string {
export function batchStatusTitle(status: BatchTaskRunStatus): string {
switch (status) {
case "PROCESSING":
return "Processing";
case "PENDING":
return "In progress";
case "COMPLETED":
return "Completed";
case "PARTIAL_FAILED":
return "Partial failure";
case "ABORTED":
return "Aborted";
default: {
@@ -53,6 +53,7 @@ export function DeploymentStatusIcon({
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
@@ -78,6 +79,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "INSTALLING":
case "BUILDING":
case "DEPLOYING":
return "text-pending";
@@ -98,6 +100,8 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
switch (status) {
case "PENDING":
return "Queued…";
case "INSTALLING":
return "Installing…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
@@ -127,17 +131,21 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"PENDING",
"INSTALLING",
"BUILDING",
"DEPLOYING",
"DEPLOYED",
"FAILED",
"TIMED_OUT",
"CANCELED",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "The deployment is queued and waiting to be processed.";
case "INSTALLING":
return "The project dependencies are being installed.";
case "BUILDING":
return "The code is being built and prepared for deployment.";
case "DEPLOYING":
@@ -1,102 +0,0 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
export function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
export function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -58,10 +58,10 @@ 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 { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
import { Button } from "../../primitives/Buttons";
import { BulkActionTypeCombo } from "./BulkAction";
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
import { AIFilterInput } from "./AIFilterInput";
import {
allTaskRunStatuses,
@@ -280,7 +280,7 @@ export function getRunFiltersFromSearchParams(
bulkId: searchParams.get("bulkId") ?? undefined,
tags:
searchParams.getAll("tags").filter((v) => v.length > 0).length > 0
? searchParams.getAll("tags").map((t) => decodeURIComponent(t))
? searchParams.getAll("tags")
: undefined,
from: searchParams.get("from") ?? undefined,
to: searchParams.get("to") ?? undefined,
@@ -810,8 +810,8 @@ function TagsDropdown({
searchValue: string;
onClose?: () => void;
}) {
const project = useProject();
const { values, replace } = useSearchParams();
const environment = useEnvironment();
const { values, value, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
@@ -822,6 +822,12 @@ function TagsDropdown({
});
};
const { period, from, to } = timeFilters({
period: value("period"),
from: value("from"),
to: value("to"),
});
const tagValues = values("tags").filter((v) => v !== "");
const selected = tagValues.length > 0 ? tagValues : undefined;
@@ -830,25 +836,34 @@ function TagsDropdown({
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
searchParams.set("name", searchValue);
}
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
}, [searchValue]);
if (period) {
searchParams.set("period", period);
}
if (from) {
searchParams.set("from", from.getTime().toString());
}
if (to) {
searchParams.set("to", to.getTime().toString());
}
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
const filtered = useMemo(() => {
let items: string[] = [];
if (searchValue === "") {
items = selected ?? [];
items = [...(selected ?? [])];
}
if (fetcher.data === undefined) {
return matchSorter(items, searchValue);
}
items.push(...fetcher.data.tags.map((t) => t.name));
items.push(...fetcher.data.tags);
return matchSorter(Array.from(new Set(items)), searchValue);
}, [searchValue, fetcher.data]);
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
@@ -958,7 +973,7 @@ function QueuesDropdown({
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", encodeURIComponent(s));
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
@@ -1220,7 +1235,7 @@ function VersionsDropdown({
(s) => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("query", encodeURIComponent(s));
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
@@ -20,6 +20,7 @@ import { TriggerIcon } from "~/assets/icons/TriggerIcon";
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
import { TraceIcon } from "~/assets/icons/TraceIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
type TaskIconProps = {
name: string | undefined;
@@ -97,6 +98,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <RunFunctionIcon className={cn(className, "text-text-dimmed")} />;
case "task-hook-init":
case "task-hook-onStart":
case "task-hook-onStartAttempt":
case "task-hook-onSuccess":
case "task-hook-onWait":
case "task-hook-onResume":
@@ -107,6 +109,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
case "task-hook-onFailure":
case "task-hook-catchError":
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
}
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
@@ -31,7 +31,7 @@ import {
type NextRunListItem,
} from "~/presenters/v3/NextRunListPresenter.server";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
import { Spinner } from "../../primitives/Spinner";
@@ -55,6 +55,8 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
type RunsTableProps = {
total: number;
@@ -62,9 +64,11 @@ type RunsTableProps = {
filters: NextRunListAppliedFilters;
showJob?: boolean;
runs: NextRunListItem[];
rootOnlyDefault?: boolean;
isLoading?: boolean;
allowSelection?: boolean;
variant?: TableVariant;
disableAdjacentRows?: boolean;
};
export function TaskRunsTable({
@@ -72,6 +76,8 @@ export function TaskRunsTable({
hasFilters,
filters,
runs,
rootOnlyDefault,
disableAdjacentRows = false,
isLoading = false,
allowSelection = false,
variant = "dimmed",
@@ -81,6 +87,12 @@ export function TaskRunsTable({
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
const { value } = useSearchParams();
const location = useOptimisticLocation();
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
const showCompute = isManagedCloud;
@@ -293,16 +305,20 @@ export function TaskRunsTable({
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run, index) => {
const searchParams = new URLSearchParams();
if (tableStateParam) {
searchParams.set("tableState", tableStateParam);
}
const path = v3RunSpanPath(organization, project, run.environment, run, {
spanId: run.spanId,
});
}, searchParams);
return (
<TableRow key={run.id}>
{allowSelection && (
<TableCell className="pl-3 pr-0">
<Checkbox
checked={has(run.friendlyId)}
onChange={(element) => {
onChange={() => {
toggle(run.friendlyId);
}}
ref={(r) => {
@@ -565,6 +581,8 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
const { tasks, from, to, ...otherFilters } = filters;
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
const testPath = singleTaskFromFilters ? v3TestTaskPath(organization, project, environment, {taskIdentifier: singleTaskFromFilters}) : v3TestPath(organization, project, environment);
if (
filters.tasks.length === 1 &&
@@ -579,7 +597,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
</Paragraph>
<div className="mt-6 flex items-center justify-center gap-2">
<LinkButton
to={v3TestPath(organization, project, environment)}
to={testPath}
variant="tertiary/medium"
LeadingIcon={BeakerIcon}
className="inline-flex"
@@ -620,7 +638,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
<LinkButton
LeadingIcon={BeakerIcon}
variant="tertiary/medium"
to={v3TestPath(organization, project, environment)}
to={testPath}
>
Run a test
</LinkButton>
@@ -330,7 +330,7 @@ function TagsDropdown({
useEffect(() => {
const searchParams = new URLSearchParams();
if (searchValue) {
searchParams.set("name", encodeURIComponent(searchValue));
searchParams.set("name", searchValue);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
+19 -14
View File
@@ -1,22 +1,28 @@
import {
createReadableStreamFromReadable,
type DataFunctionArgs,
type EntryContext,
} from "@remix-run/node"; // or cloudflare/deno
import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno
import { RemixServer } from "@remix-run/react";
import { wrapHandleErrorWithSentry } from "@sentry/remix";
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { PassThrough } from "stream";
import * as Worker from "~/services/worker.server";
import { bootstrap } from "./bootstrap";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import {
OperatingSystemContextProvider,
OperatingSystemPlatform,
} from "./components/primitives/OperatingSystemProvider";
import { Prisma } from "./db.server";
import { env } from "./env.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { logger } from "./services/logger.server";
import { resourceMonitor } from "./services/resourceMonitor.server";
import { singleton } from "./utils/singleton";
import { bootstrap } from "./bootstrap";
import { wrapHandleErrorWithSentry } from "@sentry/remix";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
import {
registerRunEngineEventBusHandlers,
setupBatchQueueCallbacks,
} from "./v3/runEngineHandlers.server";
const ABORT_DELAY = 30000;
@@ -228,18 +234,13 @@ process.on("uncaughtException", (error, origin) => {
});
singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers);
singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks);
export { apiRateLimiter } from "./services/apiRateLimit.server";
export { engineRateLimiter } from "./services/engineRateLimit.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
export { socketIo } from "./v3/handleSocketIo.server";
export { wss } from "./v3/handleWebsockets.server";
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
import { eventLoopMonitor } from "./eventLoopMonitor.server";
import { env } from "./env.server";
import { logger } from "./services/logger.server";
import { Prisma } from "./db.server";
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
eventLoopMonitor.enable();
@@ -250,3 +251,7 @@ if (remoteBuildsEnabled()) {
} else {
console.log("🏗️ Local builds enabled");
}
if (env.RESOURCE_MONITOR_ENABLED === "1") {
resourceMonitor.startMonitoring(1000);
}
+107 -5
View File
@@ -25,6 +25,27 @@ const GithubAppEnvSchema = z.preprocess(
])
);
// eventually we can make all S2 env vars required once the S2 OSS version is out
const S2EnvSchema = z.preprocess(
(val) => {
const obj = val as any;
if (!obj || !obj.S2_ENABLED) {
return { ...obj, S2_ENABLED: "0" };
}
return obj;
},
z.discriminatedUnion("S2_ENABLED", [
z.object({
S2_ENABLED: z.literal("1"),
S2_ACCESS_TOKEN: z.string(),
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
}),
z.object({
S2_ENABLED: z.literal("0"),
}),
])
);
const EnvironmentSchema = z
.object({
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
@@ -59,6 +80,7 @@ const EnvironmentSchema = z
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
APP_ORIGIN: z.string().default("http://localhost:3030"),
API_ORIGIN: z.string().optional(),
STREAM_ORIGIN: z.string().optional(),
@@ -72,6 +94,8 @@ const EnvironmentSchema = z
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
@@ -197,6 +221,7 @@ const EnvironmentSchema = z
.string()
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS: z.coerce.number().int().default(60000), // 1 minute
REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce
.number()
@@ -322,6 +347,12 @@ const EnvironmentSchema = z
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
OBJECT_STORE_REGION: z.string().optional(),
OBJECT_STORE_SERVICE: z.string().default("s3"),
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
@@ -492,10 +523,12 @@ const EnvironmentSchema = z
CENTS_PER_RUN: z.coerce.number().default(0),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
BATCH_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().optional(), // Defaults to TASK_PAYLOAD_OFFLOAD_THRESHOLD if not set
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
@@ -505,6 +538,14 @@ const EnvironmentSchema = z
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
// 2-phase batch API settings
STREAMING_BATCH_MAX_ITEMS: z.coerce.number().int().default(1_000), // Max items in streaming batch
STREAMING_BATCH_ITEM_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728),
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
REALTIME_STREAM_TTL: z.coerce
@@ -570,6 +611,12 @@ const EnvironmentSchema = z
.default(60_000),
RUN_ENGINE_SUSPENDED_HEARTBEAT_RETRIES_FACTOR: z.coerce.number().default(2),
/** Maximum duration in milliseconds that a run can be debounced. Default: 1 hour (3,600,000ms) */
RUN_ENGINE_MAXIMUM_DEBOUNCE_DURATION_MS: z.coerce
.number()
.int()
.default(60_000 * 60), // 1 hour
RUN_ENGINE_WORKER_REDIS_HOST: z
.string()
.optional()
@@ -752,8 +799,8 @@ const EnvironmentSchema = z
/** The max number of runs per API call that we'll dequeue in DEV */
DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10),
/** The maximum concurrent local run processes executing at once in dev */
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().default(25),
/** The maximum concurrent local run processes executing at once in dev. This is a hard limit */
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().optional(),
/** The CLI should connect to this for dev runs */
DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"),
@@ -899,6 +946,15 @@ const EnvironmentSchema = z
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
BATCH_TRIGGER_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
// BatchQueue DRR settings (Run Engine v2)
BATCH_QUEUE_DRR_QUANTUM: z.coerce.number().int().default(25),
BATCH_QUEUE_MAX_DEFICIT: z.coerce.number().int().default(100),
BATCH_QUEUE_CONSUMER_COUNT: z.coerce.number().int().default(3),
BATCH_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(50),
// Global rate limit: max items processed per second across all consumers
// If not set, no global rate limiting is applied
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
ADMIN_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
@@ -1028,8 +1084,9 @@ const EnvironmentSchema = z
TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"),
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000),
QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000),
DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
SLACK_BOT_TOKEN: z.string().optional(),
SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(),
@@ -1099,6 +1156,7 @@ const EnvironmentSchema = z
RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100),
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
// Clickhouse
CLICKHOUSE_URL: z.string(),
@@ -1108,6 +1166,34 @@ const EnvironmentSchema = z
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
EVENTS_CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS: z.coerce
.number()
.int()
.default(60_000 * 5), // 5 minutes
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
EVENT_REPOSITORY_DEFAULT_STORE: z
.enum(["postgres", "clickhouse", "clickhouse_v2"])
.default("postgres"),
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
// Bootstrap
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
@@ -1176,8 +1262,24 @@ const EnvironmentSchema = z
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
.number()
.int()
.default(60_000 * 60 * 24), // 1 day
REALTIME_STREAMS_S2_LOG_LEVEL: z
.enum(["log", "error", "warn", "info", "debug"])
.default("info"),
REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS: z.coerce.number().int().default(100),
REALTIME_STREAMS_S2_MAX_RETRIES: z.coerce.number().int().default(10),
REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60),
REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"),
WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000),
})
.and(GithubAppEnvSchema);
.and(GithubAppEnvSchema)
.and(S2EnvSchema);
export type Environment = z.infer<typeof EnvironmentSchema>;
export const env = EnvironmentSchema.parse(process.env);
@@ -0,0 +1,48 @@
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
type UseAutoRevalidateOptions = {
interval?: number; // in milliseconds
onFocus?: boolean;
disabled?: boolean;
};
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
const { interval = 5000, onFocus = true, disabled = false } = options;
const revalidator = useRevalidator();
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
if (revalidator.state === "loading") {
return;
}
revalidator.revalidate();
}, interval);
return () => clearInterval(intervalId);
}, [interval, disabled]);
useEffect(() => {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
revalidator.revalidate();
}
};
// Revalidate when the page becomes visible
document.addEventListener("visibilitychange", handleFocus);
// Revalidate when the window gains focus
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleFocus);
window.removeEventListener("focus", handleFocus);
};
}, [onFocus, disabled]);
return revalidator;
}
+2 -8
View File
@@ -33,12 +33,7 @@ export function useSearchParams() {
const value = useCallback(
(param: string) => {
const search = new URLSearchParams(location.search);
const val = search.get(param) ?? undefined;
if (val === undefined) {
return val;
}
return decodeURIComponent(val);
return search.get(param) ?? undefined;
},
[location]
);
@@ -46,8 +41,7 @@ export function useSearchParams() {
const values = useCallback(
(param: string) => {
const search = new URLSearchParams(location.search);
const all = search.getAll(param);
return all.map((v) => decodeURIComponent(v));
return search.getAll(param);
},
[location]
);
+58 -33
View File
@@ -1,5 +1,9 @@
import { prisma } from "~/db.server";
import { type Prisma, prisma } from "~/db.server";
import { createEnvironment } from "./organization.server";
import { customAlphabet } from "nanoid";
const tokenValueLength = 40;
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
export async function getTeamMembersAndInvites({
userId,
@@ -95,14 +99,19 @@ export async function inviteMembers({
throw new Error("User does not have access to this organization");
}
const created = await prisma.orgMemberInvite.createMany({
data: emails.map((email) => ({
email,
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
})),
skipDuplicates: true,
const invites = [...new Set(emails)].map(
(email) =>
({
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
} satisfies Prisma.OrgMemberInviteCreateManyInput)
);
await prisma.orgMemberInvite.createMany({
data: invites,
});
return await prisma.orgMemberInvite.findMany({
@@ -147,12 +156,19 @@ export async function getUsersInvites({ email }: { email: string }) {
});
}
export async function acceptInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
export async function acceptInvite({
user,
inviteId,
}: {
user: { id: string; email: string };
inviteId: string;
}) {
return await prisma.$transaction(async (tx) => {
// 1. Delete the invite and get the invite details
const invite = await tx.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
include: {
organization: {
@@ -167,7 +183,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
const member = await tx.orgMember.create({
data: {
organizationId: invite.organizationId,
userId,
userId: user.id,
role: invite.role,
},
});
@@ -187,7 +203,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
// 4. Check for other invites
const remainingInvites = await tx.orgMemberInvite.findMany({
where: {
email: invite.email,
email: user.email,
},
});
@@ -195,28 +211,29 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
});
}
export async function declineInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
export async function declineInvite({
user,
inviteId,
}: {
user: { id: string; email: string };
inviteId: string;
}) {
return await prisma.$transaction(async (tx) => {
//1. delete invite
const declinedInvite = await prisma.orgMemberInvite.delete({
where: {
id: inviteId,
email: user.email,
},
include: {
organization: true,
},
});
//2. get email
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
//3. check for other invites
//2. check for other invites
const remainingInvites = await prisma.orgMemberInvite.findMany({
where: {
email: user!.email,
email: user.email,
},
});
@@ -224,10 +241,11 @@ export async function declineInvite({ userId, inviteId }: { userId: string; invi
});
}
export async function resendInvite({ inviteId }: { inviteId: string }) {
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
return await prisma.orgMemberInvite.update({
where: {
id: inviteId,
inviterId: userId,
},
data: {
updatedAt: new Date(),
@@ -241,26 +259,27 @@ export async function resendInvite({ inviteId }: { inviteId: string }) {
export async function revokeInvite({
userId,
slug,
orgSlug,
inviteId,
}: {
userId: string;
slug: string;
orgSlug: string;
inviteId: string;
}) {
const org = await prisma.organization.findFirst({
where: { slug, members: { some: { userId } } },
});
if (!org) {
throw new Error("User does not have access to this organization");
}
const invite = await prisma.orgMemberInvite.delete({
const invite = await prisma.orgMemberInvite.findFirst({
where: {
id: inviteId,
organizationId: org.id,
organization: {
slug: orgSlug,
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
email: true,
organization: true,
},
@@ -270,5 +289,11 @@ export async function revokeInvite({
throw new Error("Invite not found");
}
await prisma.orgMemberInvite.delete({
where: {
id: invite.id,
},
});
return { email: invite.email, organization: invite.organization };
}
+22 -2
View File
@@ -1,7 +1,8 @@
import { json, Session } from "@remix-run/node";
import { createCookieSessionStorage } from "@remix-run/node";
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
import { redirect, typedjson } from "remix-typedjson";
import { ButtonVariant } from "~/components/primitives/Buttons";
import { env } from "~/env.server";
import { type FeedbackType } from "~/routes/resources.feedback";
export type ToastMessage = {
message: string;
@@ -9,9 +10,26 @@ export type ToastMessage = {
options: Required<ToastMessageOptions>;
};
export type ToastMessageAction = {
label: string;
variant?: ButtonVariant;
action:
| {
type: "link";
path: string;
}
| {
type: "help";
feedbackType: FeedbackType;
};
};
export type ToastMessageOptions = {
title?: string;
/** Ephemeral means it disappears after a delay, defaults to true */
ephemeral?: boolean;
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
action?: ToastMessageAction;
};
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
@@ -36,6 +54,7 @@ export function setSuccessMessage(
message,
type: "success",
options: {
...options,
ephemeral: options?.ephemeral ?? true,
},
} as ToastMessage);
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
message,
type: "error",
options: {
...options,
ephemeral: options?.ephemeral ?? true,
},
} as ToastMessage);
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
import { env } from "~/env.server";
import { featuresForUrl } from "~/features.server";
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
export type { Organization };
const nanoid = customAlphabet("1234567890abcdef", 4);
@@ -66,7 +66,7 @@ export async function createOrganization(
role: "ADMIN",
},
},
v3Enabled: !features.isManagedCloud,
v3Enabled: true,
},
include: {
members: true,
@@ -96,6 +96,8 @@ export async function createEnvironment({
const pkApiKey = createPkApiKeyForEnv(type);
const shortcode = createShortcode().join("-");
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
return await prismaClient.runtimeEnvironment.create({
data: {
slug,
@@ -103,7 +105,7 @@ export async function createEnvironment({
pkApiKey,
shortcode,
autoEnableInternalSources: type !== "DEVELOPMENT",
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
maximumConcurrencyLimit: limit,
organization: {
connect: {
id: organization.id,
+27
View File
@@ -16,12 +16,26 @@ type Options = {
version: "v2" | "v3";
};
export class ExceededProjectLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "ExceededProjectLimitError";
}
}
export async function createProject(
{ organizationSlug, name, userId, version }: Options,
attemptCount = 0
): Promise<Project & { organization: Organization }> {
//check the user has permissions to do this
const organization = await prisma.organization.findFirst({
select: {
id: true,
slug: true,
v3Enabled: true,
maximumConcurrencyLimit: true,
maximumProjectCount: true,
},
where: {
slug: organizationSlug,
members: { some: { userId } },
@@ -40,6 +54,19 @@ export async function createProject(
}
}
const projectCount = await prisma.project.count({
where: {
organizationId: organization.id,
deletedAt: null,
},
});
if (projectCount >= organization.maximumProjectCount) {
throw new ExceededProjectLimitError(
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
);
}
//ensure the slug is globally unique
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
const projectWithSameSlug = await prisma.project.findFirst({
+142 -1
View File
@@ -1,5 +1,6 @@
import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import type { GoogleProfile } from "remix-auth-google";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import {
@@ -8,6 +9,8 @@ import {
} from "~/services/dashboardPreferences.server";
export type { User } from "@trigger.dev/database";
import { assertEmailAllowed } from "~/utils/email";
import { logger } from "~/services/logger.server";
type FindOrCreateMagicLink = {
authenticationMethod: "MAGIC_LINK";
email: string;
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
type FindOrCreateGoogle = {
authenticationMethod: "GOOGLE";
email: User["email"];
authenticationProfile: GoogleProfile;
authenticationExtraParams: Record<string, unknown>;
};
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
type LoggedInUser = {
user: User;
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
case "MAGIC_LINK": {
return findOrCreateMagicLinkUser(input);
}
case "GOOGLE": {
return findOrCreateGoogleUser(input);
}
}
}
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
};
}
export async function findOrCreateGoogleUser({
email,
authenticationProfile,
authenticationExtraParams,
}: FindOrCreateGoogle): Promise<LoggedInUser> {
assertEmailAllowed(email);
const name = authenticationProfile._json.name;
let avatarUrl: string | undefined = undefined;
if (authenticationProfile.photos[0]) {
avatarUrl = authenticationProfile.photos[0].value;
}
const displayName = authenticationProfile.displayName;
const authProfile = authenticationProfile
? (authenticationProfile as unknown as Prisma.JsonObject)
: undefined;
const authExtraParams = authenticationExtraParams
? (authenticationExtraParams as unknown as Prisma.JsonObject)
: undefined;
const authIdentifier = `google:${authenticationProfile.id}`;
const existingUser = await prisma.user.findUnique({
where: {
authIdentifier,
},
});
const existingEmailUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingEmailUser && !existingUser) {
// Link existing email account to Google auth, preserving original authenticationMethod
const user = await prisma.user.update({
where: {
email,
},
data: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
avatarUrl,
authIdentifier,
},
});
return {
user,
isNewUser: false,
};
}
if (existingEmailUser && existingUser) {
// Check if email user and auth user are the same
if (existingEmailUser.id !== existingUser.id) {
// Different users: email is taken by one user, Google auth belongs to another
logger.error(
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
{
email,
existingEmailUserId: existingEmailUser.id,
existingAuthUserId: existingUser.id,
authIdentifier,
}
);
return {
user: existingUser,
isNewUser: false,
};
}
// Same user: update all profile fields
const user = await prisma.user.update({
where: {
id: existingUser.id,
},
data: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
});
return {
user,
isNewUser: false,
};
}
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
// It's not possible that the email is already taken by a different user because that would have been handled
// by one of the if statements above.
const user = await prisma.user.upsert({
where: {
authIdentifier,
},
update: {
email,
displayName,
name,
avatarUrl,
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
},
create: {
authenticationProfile: authProfile,
authenticationExtraParams: authExtraParams,
name,
avatarUrl,
displayName,
authIdentifier,
email,
authenticationMethod: "GOOGLE",
},
});
return {
user,
isNewUser: !existingUser,
};
}
export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
@@ -75,6 +75,7 @@ export class ApiRetrieveRunPresenter {
},
select: {
...commonRunSelect,
traceId: true,
payload: true,
payloadType: true,
output: true,
@@ -195,7 +195,7 @@ WHERE
throw new Error(`Environment not found for Batch ${batch.id}`);
}
const hasFinished = batch.status !== "PENDING";
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
return {
id: batch.id,
@@ -0,0 +1,122 @@
import { type BatchTaskRunStatus } from "@trigger.dev/database";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
type BatchPresenterOptions = {
environmentId: string;
batchId: string;
userId?: string;
};
export type BatchPresenterData = Awaited<ReturnType<BatchPresenter["call"]>>;
export class BatchPresenter extends BasePresenter {
public async call({ environmentId, batchId, userId }: BatchPresenterOptions) {
const batch = await this._replica.batchTaskRun.findFirst({
select: {
id: true,
friendlyId: true,
status: true,
runCount: true,
batchVersion: true,
createdAt: true,
updatedAt: true,
completedAt: true,
processingStartedAt: true,
processingCompletedAt: true,
successfulRunCount: true,
failedRunCount: true,
idempotencyKey: true,
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
errors: {
select: {
id: true,
index: true,
taskIdentifier: true,
error: true,
errorCode: true,
createdAt: true,
},
orderBy: {
index: "asc",
},
},
},
where: {
runtimeEnvironmentId: environmentId,
friendlyId: batchId,
},
});
if (!batch) {
throw new Error("Batch not found");
}
const hasFinished = batch.status !== "PENDING" && batch.status !== "PROCESSING";
const isV2 = batch.batchVersion === "runengine:v2";
// For v2 batches in PROCESSING state, get live progress from Redis
// This provides real-time updates without waiting for the batch to complete
let liveSuccessCount = batch.successfulRunCount ?? 0;
let liveFailureCount = batch.failedRunCount ?? 0;
if (isV2 && batch.status === "PROCESSING") {
const liveProgress = await engine.getBatchQueueProgress(batch.id);
if (liveProgress) {
liveSuccessCount = liveProgress.successCount;
liveFailureCount = liveProgress.failureCount;
}
}
return {
id: batch.id,
friendlyId: batch.friendlyId,
status: batch.status as BatchTaskRunStatus,
runCount: batch.runCount,
batchVersion: batch.batchVersion,
isV2,
createdAt: batch.createdAt.toISOString(),
updatedAt: batch.updatedAt.toISOString(),
completedAt: batch.completedAt?.toISOString(),
processingStartedAt: batch.processingStartedAt?.toISOString(),
processingCompletedAt: batch.processingCompletedAt?.toISOString(),
finishedAt: batch.completedAt
? batch.completedAt.toISOString()
: hasFinished
? batch.updatedAt.toISOString()
: undefined,
hasFinished,
successfulRunCount: liveSuccessCount,
failedRunCount: liveFailureCount,
idempotencyKey: batch.idempotencyKey,
environment: displayableEnvironment(batch.runtimeEnvironment, userId),
errors: batch.errors.map((error) => ({
id: error.id,
index: error.index,
taskIdentifier: error.taskIdentifier,
error: error.error,
errorCode: error.errorCode,
createdAt: error.createdAt.toISOString(),
})),
};
}
}
@@ -1,9 +1,10 @@
import {
BuildServerMetadata,
DeploymentErrorData,
ExternalBuildData,
prepareDeploymentError,
} from "@trigger.dev/core/v3";
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
import { type PrismaClient, prisma } from "~/db.server";
import { type Organization } from "~/models/organization.server";
import { type Project } from "~/models/project.server";
@@ -11,6 +12,24 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { processGitMetadata } from "./BranchesPresenter.server";
import { S2 } from "@s2-dev/streamstore";
import { env } from "~/env.server";
import { createRedisClient } from "~/redis.server";
import { tryCatch } from "@trigger.dev/core";
import { logger } from "~/services/logger.server";
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
const s2TokenRedis = createRedisClient("s2-token-cache", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
});
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
export type ErrorData = {
name: string;
@@ -43,6 +62,7 @@ export class DeploymentPresenter {
select: {
id: true,
organizationId: true,
externalRef: true,
},
where: {
slug: projectSlug,
@@ -103,6 +123,9 @@ export class DeploymentPresenter {
deployedAt: true,
createdAt: true,
startedAt: true,
installedAt: true,
canceledAt: true,
canceledReason: true,
git: true,
promotions: {
select: {
@@ -132,14 +155,40 @@ export class DeploymentPresenter {
avatarUrl: true,
},
},
buildServerMetadata: true,
},
});
const gitMetadata = processGitMetadata(deployment.git);
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
const buildServerMetadata = deployment.buildServerMetadata
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
: undefined;
let eventStream = undefined;
if (
env.S2_ENABLED === "1" &&
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
) {
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
if (error) {
logger.error("Failed getting S2 access token", { error });
} else {
eventStream = {
s2: {
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
accessToken,
},
};
}
}
return {
eventStream,
deployment: {
id: deployment.id,
shortCode: deployment.shortCode,
@@ -147,8 +196,11 @@ export class DeploymentPresenter {
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
installedAt: deployment.installedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
canceledAt: deployment.canceledAt,
canceledReason: deployment.canceledReason,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
@@ -172,11 +224,46 @@ export class DeploymentPresenter {
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
isBuilt: !!deployment.builtAt,
type: deployment.type,
git: processGitMetadata(deployment.git),
git: gitMetadata,
},
};
}
private async getS2AccessToken(projectRef: string): Promise<string> {
if (env.S2_ENABLED !== "1" || !s2) {
throw new Error("Failed getting S2 access token: S2 is not enabled");
}
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
const cachedToken = await s2TokenRedis.get(redisKey);
if (cachedToken) {
return cachedToken;
}
const { access_token: accessToken } = await s2.accessTokens.issue({
id: `${projectRef}-${new Date().getTime()}`,
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
scope: {
ops: ["read"],
basins: {
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
},
streams: {
prefix: `projects/${projectRef}/deployments/`,
},
},
});
await s2TokenRedis.setex(
redisKey,
59 * 60, // slightly shorter than the token validity period
accessToken
);
return accessToken;
}
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
@@ -0,0 +1,137 @@
import { type PrismaClient } from "@trigger.dev/database";
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
import { env } from "~/env.server";
import { BranchTrackingConfigSchema } from "~/v3/github";
import { BasePresenter } from "./basePresenter.server";
type GitHubSettingsOptions = {
projectId: string;
organizationId: string;
};
export class GitHubSettingsPresenter extends BasePresenter {
public call({ projectId, organizationId }: GitHubSettingsOptions) {
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
if (!githubAppEnabled) {
return ok({
enabled: false,
connectedRepository: undefined,
installations: undefined,
isPreviewEnvironmentEnabled: undefined,
});
}
const findConnectedGithubRepository = () =>
fromPromise(
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
where: {
projectId,
repository: {
installation: {
deletedAt: null,
suspendedAt: null,
},
},
},
select: {
branchTracking: true,
previewDeploymentsEnabled: true,
createdAt: true,
repository: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
},
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((connectedGithubRepository) => {
if (!connectedGithubRepository) {
return undefined;
}
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
connectedGithubRepository.branchTracking
);
const branchTracking = branchTrackingOrFailure.success
? branchTrackingOrFailure.data
: undefined;
return {
...connectedGithubRepository,
branchTracking,
};
});
const listGithubAppInstallations = () =>
fromPromise(
(this._replica as PrismaClient).githubAppInstallation.findMany({
where: {
organizationId,
deletedAt: null,
suspendedAt: null,
},
select: {
id: true,
accountHandle: true,
targetType: true,
appInstallationId: true,
repositories: {
select: {
id: true,
name: true,
fullName: true,
htmlUrl: true,
private: true,
},
take: 200,
},
},
take: 20,
orderBy: {
createdAt: "desc",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
);
const isPreviewEnvironmentEnabled = () =>
fromPromise(
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: projectId,
slug: "preview",
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).map((previewEnvironment) => previewEnvironment !== null);
return ResultAsync.combine([
isPreviewEnvironmentEnabled(),
findConnectedGithubRepository(),
listGithubAppInstallations(),
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
enabled: true,
connectedRepository: connectedGithubRepository,
installations: githubAppInstallations,
isPreviewEnvironmentEnabled,
}));
}
}
@@ -0,0 +1,141 @@
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import {
getCurrentPlan,
getDefaultEnvironmentLimitFromPlan,
getPlans,
} from "~/services/platform.v3.server";
import { BasePresenter } from "./basePresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
export type ConcurrencyResult = {
canAddConcurrency: boolean;
environments: EnvironmentWithConcurrency[];
extraConcurrency: number;
extraAllocatedConcurrency: number;
extraUnallocatedConcurrency: number;
maxQuota: number;
concurrencyPricing: {
stepSize: number;
centsPerStep: number;
};
};
export type EnvironmentWithConcurrency = {
id: string;
type: RuntimeEnvironmentType;
isBranchableEnvironment: boolean;
branchName: string | null;
parentEnvironmentId: string | null;
maximumConcurrencyLimit: number;
planConcurrencyLimit: number;
};
export class ManageConcurrencyPresenter extends BasePresenter {
public async call({
userId,
projectId,
organizationId,
}: {
userId: string;
projectId: string;
organizationId: string;
}): Promise<ConcurrencyResult> {
// Get plan
const currentPlan = await getCurrentPlan(organizationId);
if (!currentPlan) {
throw new Error("No plan found");
}
const canAddConcurrency =
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
const environments = await this._replica.runtimeEnvironment.findMany({
select: {
id: true,
projectId: true,
type: true,
branchName: true,
parentEnvironmentId: true,
isBranchableEnvironment: true,
maximumConcurrencyLimit: true,
orgMember: {
select: {
userId: true,
},
},
project: {
select: {
deletedAt: true,
},
},
},
where: {
organizationId,
archivedAt: null,
},
});
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
// Go through all environments and add up extra concurrency above their allowed allocation
let extraAllocatedConcurrency = 0;
const projectEnvironments: EnvironmentWithConcurrency[] = [];
for (const environment of environments) {
// Don't count parent environments
if (environment.isBranchableEnvironment) continue;
// Don't count deleted projects
if (environment.project.deletedAt) continue;
const limit = currentPlan
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
: 0;
if (!limit) continue;
// If it's not DEV and they've increased, track that
// You can't spend money to increase DEV concurrency
if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
}
// We only want to show this project's environments
if (environment.projectId === projectId) {
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
continue;
}
projectEnvironments.push({
id: environment.id,
type: environment.type,
isBranchableEnvironment: environment.isBranchableEnvironment,
branchName: environment.branchName,
parentEnvironmentId: environment.parentEnvironmentId,
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
planConcurrencyLimit: limit,
});
}
}
const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
const plans = await getPlans();
if (!plans) {
throw new Error("Couldn't retrieve add on pricing");
}
return {
canAddConcurrency,
extraConcurrency,
extraAllocatedConcurrency: extraAllocated,
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
environments: sortEnvironments(projectEnvironments, [
"PRODUCTION",
"STAGING",
"PREVIEW",
"DEVELOPMENT",
]),
concurrencyPricing: plans.addOnPricing.concurrency,
};
}
}
@@ -114,6 +114,9 @@ export class QueueListPresenter extends BasePresenter {
name: true,
orderableName: true,
concurrencyLimit: true,
concurrencyLimitBase: true,
concurrencyLimitOverriddenAt: true,
concurrencyLimitOverriddenBy: true,
type: true,
paused: true,
},
@@ -135,6 +138,17 @@ export class QueueListPresenter extends BasePresenter {
),
]);
// Manually "join" the overridden users because there is no way to implement the relationship
// in prisma without adding a foreign key constraint
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
const overriddenByUsers = await this._replica.user.findMany({
where: {
id: { in: overriddenByIds },
},
});
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
// Transform queues to include running and queued counts
return queues.map((queue) =>
toQueueItem({
@@ -144,6 +158,11 @@ export class QueueListPresenter extends BasePresenter {
running: results[1][queue.name] ?? 0,
queued: results[0][queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy
? overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null
: null,
paused: queue.paused,
})
);
@@ -1,12 +1,18 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { engine } from "~/v3/runEngine.server";
import { BasePresenter } from "./basePresenter.server";
import { type TaskQueueType } from "@trigger.dev/database";
import { TaskQueue, User, type TaskQueueType } from "@trigger.dev/database";
import { assertExhaustive } from "@trigger.dev/core";
import { determineEngineVersion } from "~/v3/engineVersion.server";
import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
import { type Prettify, type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
import { PrismaClientOrTransaction } from "@trigger.dev/database";
export type FoundQueue = Prettify<
Omit<TaskQueue, "concurrencyLimitOverriddenBy"> & {
concurrencyLimitOverriddenBy?: User | null;
}
>;
/**
* Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService
*/
@@ -16,22 +22,50 @@ export async function getQueue(
queue: RetrieveQueueParam
) {
if (typeof queue === "string") {
return prismaClient.taskQueue.findFirst({
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
},
});
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
friendlyId: queue,
runtimeEnvironmentId: environment.id,
},
})
);
}
const queueName =
queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name;
return prismaClient.taskQueue.findFirst({
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
},
return joinQueueWithUser(
prismaClient,
await prismaClient.taskQueue.findFirst({
where: {
name: queueName,
runtimeEnvironmentId: environment.id,
},
})
);
}
async function joinQueueWithUser(
prismaClient: PrismaClientOrTransaction,
queue?: TaskQueue | null
): Promise<FoundQueue | undefined> {
if (!queue) return undefined;
if (!queue.concurrencyLimitOverriddenBy) {
return {
...queue,
concurrencyLimitOverriddenBy: undefined,
};
}
const user = await prismaClient.user.findFirst({
where: { id: queue.concurrencyLimitOverriddenBy },
});
return {
...queue,
concurrencyLimitOverriddenBy: user,
};
}
export class QueueRetrievePresenter extends BasePresenter {
@@ -75,6 +109,9 @@ export class QueueRetrievePresenter extends BasePresenter {
running: results[1]?.[queue.name] ?? 0,
queued: results[0]?.[queue.name] ?? 0,
concurrencyLimit: queue.concurrencyLimit ?? null,
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
paused: queue.paused,
}),
};
@@ -104,6 +141,9 @@ export function toQueueItem(data: {
running: number;
queued: number;
concurrencyLimit: number | null;
concurrencyLimitBase: number | null;
concurrencyLimitOverriddenAt: Date | null;
concurrencyLimitOverriddenBy: User | null;
paused: boolean;
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
return {
@@ -113,9 +153,22 @@ export function toQueueItem(data: {
type: queueTypeFromType(data.type),
running: data.running,
queued: data.queued,
concurrencyLimit: data.concurrencyLimit,
paused: data.paused,
concurrencyLimit: data.concurrencyLimit,
concurrency: {
current: data.concurrencyLimit,
base: data.concurrencyLimitBase,
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
overriddenAt: data.concurrencyLimitOverriddenAt,
},
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
releaseConcurrencyOnWaitpoint: true,
};
}
function toQueueConcurrencyOverriddenBy(user: User | null) {
if (!user) return null;
return user.displayName ?? user.name ?? null;
}
@@ -3,9 +3,11 @@ import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/Tr
import { prisma, type PrismaClient } from "~/db.server";
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
import { getUsername } from "~/utils/username";
import { eventRepository } from "~/v3/eventRepository.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
import { env } from "~/env.server";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
@@ -28,7 +30,6 @@ export class RunPresenter {
public async call({
userId,
projectSlug,
organizationSlug,
environmentSlug,
runFriendlyId,
showDeletedLogs,
@@ -36,7 +37,6 @@ export class RunPresenter {
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
environmentSlug: string;
runFriendlyId: string;
showDeletedLogs: boolean;
@@ -47,9 +47,11 @@ export class RunPresenter {
id: true,
createdAt: true,
taskEventStore: true,
taskIdentifier: true,
number: true,
traceId: true,
spanId: true,
parentSpanId: true,
friendlyId: true,
status: true,
startedAt: true,
@@ -93,6 +95,13 @@ export class RunPresenter {
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
},
});
@@ -132,21 +141,56 @@ export class RunPresenter {
return {
run: runData,
trace: undefined,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
};
}
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
// get the events
const traceSummary = await eventRepository.getTraceSummary(
let traceSummary = await eventRepository.getTraceSummary(
getTaskEventStoreTableForRun(run),
run.runtimeEnvironment.id,
run.traceId,
run.rootTaskRun?.createdAt ?? run.createdAt,
run.completedAt ?? undefined,
{ includeDebugLogs: showDebug }
);
if (!traceSummary) {
return {
run: runData,
trace: undefined,
const spanSummary: SpanSummary = {
id: run.spanId,
parentId: run.parentSpanId ?? undefined,
runId: run.friendlyId,
data: {
message: run.taskIdentifier,
style: { icon: "task", variant: "primary" },
events: [],
startTime: run.createdAt,
duration: 0,
isError:
run.status === "COMPLETED_WITH_ERRORS" ||
run.status === "CRASHED" ||
run.status === "EXPIRED" ||
run.status === "SYSTEM_FAILURE" ||
run.status === "TIMED_OUT",
isPartial:
run.status === "DELAYED" ||
run.status === "PENDING" ||
run.status === "PAUSED" ||
run.status === "RETRYING_AFTER_FAILURE" ||
run.status === "DEQUEUED" ||
run.status === "EXECUTING" ||
run.status === "WAITING_TO_RESUME",
isCancelled: run.status === "CANCELED",
isDebug: false,
level: "TRACE",
},
};
traceSummary = {
rootSpan: spanSummary,
spans: [spanSummary],
};
}
@@ -215,7 +259,9 @@ export class RunPresenter {
queuedDuration: run.startedAt
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
: undefined,
overridesBySpanId: traceSummary.overridesBySpanId,
},
maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting,
};
}
}
@@ -3,7 +3,7 @@ import { eventStream } from "remix-utils/sse/server";
import { PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { throttle } from "~/utils/throttle";
import { eventRepository } from "~/v3/eventRepository.server";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
const pingInterval = 1000;
@@ -41,7 +41,7 @@ export class RunStreamPresenter {
let pinger: NodeJS.Timeout | undefined = undefined;
const { unsubscribe, eventEmitter } = await eventRepository.subscribeToTrace(run.traceId);
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
return eventStream(request.signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
@@ -1,8 +1,16 @@
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { BasePresenter } from "./basePresenter.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { type PrismaClient } from "@trigger.dev/database";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
export type TagListOptions = {
userId?: string;
organizationId: string;
environmentId: string;
projectId: string;
period?: string;
from?: Date;
to?: Date;
//filters
name?: string;
//pagination
@@ -17,40 +25,39 @@ export type TagListItem = TagList["tags"][number];
export class RunTagListPresenter extends BasePresenter {
public async call({
userId,
organizationId,
environmentId,
projectId,
name,
period,
from,
to,
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
}: TagListOptions) {
const hasFilters = Boolean(name?.trim());
const tags = await this._replica.taskRunTag.findMany({
where: {
projectId,
name: name
? {
startsWith: name,
mode: "insensitive",
}
: undefined,
},
orderBy: {
id: "desc",
},
take: pageSize + 1,
skip: (page - 1) * pageSize,
const runsRepository = new RunsRepository({
clickhouse: clickhouseClient,
prisma: this._replica as PrismaClient,
});
const tags = await runsRepository.listTags({
organizationId,
projectId,
environmentId,
query: name,
period,
from: from ? from.getTime() : undefined,
to: to ? to.getTime() : undefined,
offset: (page - 1) * pageSize,
limit: pageSize + 1,
});
return {
tags: tags
.map((tag) => ({
id: tag.friendlyId,
name: tag.name,
}))
.slice(0, pageSize),
tags: tags.tags,
currentPage: page,
hasMore: tags.length > pageSize,
hasMore: tags.tags.length > pageSize,
hasFilters,
};
}
@@ -9,14 +9,18 @@ import {
} from "@trigger.dev/core/v3";
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logger } from "~/services/logger.server";
import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server";
import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server";
import { machinePresetFromRun } from "~/v3/machinePresets.server";
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { WaitpointPresenter } from "./WaitpointPresenter.server";
import { engine } from "~/v3/runEngine.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
import { safeJsonParse } from "~/utils/json";
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
export type Span = NonNullable<NonNullable<Result>["span"]>;
@@ -24,14 +28,16 @@ export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
type FindRunResult = NonNullable<
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
>;
type GetSpanResult = NonNullable<Awaited<ReturnType<(typeof eventRepository)["getSpan"]>>>;
type GetSpanResult = SpanDetail;
export class SpanPresenter extends BasePresenter {
public async call({
userId,
projectSlug,
spanId,
runFriendlyId,
}: {
userId: string;
projectSlug: string;
spanId: string;
runFriendlyId: string;
@@ -39,6 +45,13 @@ export class SpanPresenter extends BasePresenter {
const project = await this._replica.project.findFirst({
where: {
slug: projectSlug,
organization: {
members: {
some: {
userId,
},
},
},
},
});
@@ -57,6 +70,7 @@ export class SpanPresenter extends BasePresenter {
},
where: {
friendlyId: runFriendlyId,
projectId: project.id,
},
});
@@ -66,14 +80,18 @@ export class SpanPresenter extends BasePresenter {
const { traceId } = parentRun;
const eventRepository = resolveEventRepositoryForStore(parentRun.taskEventStore);
const eventStore = getTaskEventStoreTableForRun(parentRun);
const run = await this.getRun({
eventStore,
traceId,
eventRepository,
spanId,
createdAt: parentRun.createdAt,
completedAt: parentRun.completedAt,
environmentId: parentRun.runtimeEnvironmentId,
});
if (run) {
return {
@@ -82,15 +100,15 @@ export class SpanPresenter extends BasePresenter {
};
}
//get the run
const span = await this.#getSpan({
eventStore,
traceId,
spanId,
traceId,
environmentId: parentRun.runtimeEnvironmentId,
projectId: parentRun.projectId,
createdAt: parentRun.createdAt,
completedAt: parentRun.completedAt,
eventRepository,
});
if (!span) {
@@ -105,30 +123,31 @@ export class SpanPresenter extends BasePresenter {
async getRun({
eventStore,
environmentId,
traceId,
eventRepository,
spanId,
createdAt,
completedAt,
}: {
eventStore: TaskEventStoreTable;
environmentId: string;
traceId: string;
eventRepository: IEventRepository;
spanId: string;
createdAt: Date;
completedAt: Date | null;
}) {
const span = await eventRepository.getSpan(
const originalRunId = await eventRepository.getSpanOriginalRunId(
eventStore,
environmentId,
spanId,
traceId,
createdAt,
completedAt ?? undefined
);
if (!span) {
return;
}
const run = await this.findRun({ span, spanId });
const run = await this.findRun({ originalRunId, spanId, environmentId });
if (!run) {
return;
@@ -192,6 +211,14 @@ export class SpanPresenter extends BasePresenter {
region = workerGroup ?? null;
}
// Query for runs that are replays of this run (from ClickHouse)
const replays = await this.#getRunReplays(
run.project.organization.id,
run.project.id,
run.runtimeEnvironment.id,
run.friendlyId
);
return {
id: run.id,
friendlyId: run.friendlyId,
@@ -216,6 +243,7 @@ export class SpanPresenter extends BasePresenter {
environmentId: run.runtimeEnvironment.id,
idempotencyKey: run.idempotencyKey,
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
queue: {
name: run.queue,
@@ -251,13 +279,57 @@ export class SpanPresenter extends BasePresenter {
engine: run.engine,
region,
workerQueue: run.workerQueue,
traceId: run.traceId,
spanId: run.spanId,
isCached: !!span.originalRun,
isCached: !!originalRunId,
machinePreset: machine?.name,
taskEventStore: run.taskEventStore,
externalTraceId,
replays,
};
}
async #getRunReplays(
organizationId: string,
projectId: string,
environmentId: string,
friendlyId: string
): Promise<Array<{ friendlyId: string; status: string }>> {
try {
const [error, result] = await clickhouseClient.taskRuns.getRunReplays({
organizationId,
projectId,
environmentId,
replayedFromFriendlyId: friendlyId,
});
if (error) {
logger.error("Error fetching run replays from ClickHouse", {
error,
organizationId,
projectId,
environmentId,
friendlyId,
});
return [];
}
return result.map((row) => ({
friendlyId: row.friendly_id,
status: row.status,
}));
} catch (error) {
logger.error("Error fetching run replays from ClickHouse", {
error,
organizationId,
projectId,
environmentId,
friendlyId,
});
return [];
}
}
async resolveSchedule(scheduleId?: string) {
if (!scheduleId) {
return;
@@ -287,7 +359,15 @@ export class SpanPresenter extends BasePresenter {
};
}
async findRun({ span, spanId }: { span: GetSpanResult; spanId: string }) {
async findRun({
originalRunId,
spanId,
environmentId,
}: {
originalRunId?: string;
spanId: string;
environmentId: string;
}) {
const run = await this._replica.taskRun.findFirst({
select: {
id: true,
@@ -329,6 +409,8 @@ export class SpanPresenter extends BasePresenter {
//idempotency
idempotencyKey: true,
idempotencyKeyExpiresAt: true,
//debounce
debounce: true,
//delayed
delayUntil: true,
//ttl
@@ -397,12 +479,14 @@ export class SpanPresenter extends BasePresenter {
},
},
},
where: span.originalRun
where: originalRunId
? {
friendlyId: span.originalRun,
friendlyId: originalRunId,
runtimeEnvironmentId: environmentId,
}
: {
spanId,
runtimeEnvironmentId: environmentId,
},
});
@@ -411,6 +495,7 @@ export class SpanPresenter extends BasePresenter {
async #getSpan({
eventStore,
eventRepository,
traceId,
spanId,
environmentId,
@@ -418,6 +503,7 @@ export class SpanPresenter extends BasePresenter {
createdAt,
completedAt,
}: {
eventRepository: IEventRepository;
traceId: string;
spanId: string;
environmentId: string;
@@ -428,12 +514,14 @@ export class SpanPresenter extends BasePresenter {
}) {
const span = await eventRepository.getSpan(
eventStore,
environmentId,
spanId,
traceId,
createdAt,
completedAt ?? undefined,
{ includeDebugLogs: true }
);
if (!span) {
return;
}
@@ -445,11 +533,7 @@ export class SpanPresenter extends BasePresenter {
spanId: true,
createdAt: true,
number: true,
lockedToVersion: {
select: {
version: true,
},
},
taskVersion: true,
},
where: {
parentSpanId: spanId,
@@ -457,11 +541,32 @@ export class SpanPresenter extends BasePresenter {
});
const data = {
...span,
spanId: span.spanId,
parentId: span.parentId,
message: span.message,
isError: span.isError,
isPartial: span.isPartial,
isCancelled: span.isCancelled,
level: span.level,
startTime: span.startTime,
duration: span.duration,
events: span.events,
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
style: span.style,
properties:
span.properties &&
typeof span.properties === "object" &&
Object.keys(span.properties).length > 0
? JSON.stringify(span.properties, null, 2)
: undefined,
resourceProperties:
span.resourceProperties &&
typeof span.resourceProperties === "object" &&
Object.keys(span.resourceProperties).length > 0
? JSON.stringify(span.resourceProperties, null, 2)
: undefined,
entity: span.entity,
metadata: span.metadata,
triggeredRuns,
showActionBar: span.show?.actions === true,
};
switch (span.entity.type) {
@@ -513,6 +618,41 @@ export class SpanPresenter extends BasePresenter {
},
};
}
case "realtime-stream": {
if (!span.entity.id) {
logger.error(`SpanPresenter: No realtime stream id`, {
spanId,
realtimeStreamId: span.entity.id,
});
return { ...data, entity: null };
}
const [runId, streamKey] = span.entity.id.split(":");
if (!runId || !streamKey) {
logger.error(`SpanPresenter: Invalid realtime stream id`, {
spanId,
realtimeStreamId: span.entity.id,
});
return { ...data, entity: null };
}
const metadata = span.entity.metadata
? (safeJsonParse(span.entity.metadata) as Record<string, unknown> | undefined)
: undefined;
return {
...data,
entity: {
type: "realtime-stream" as const,
object: {
runId,
streamKey,
metadata,
},
},
};
}
default:
return { ...data, entity: null };
}
@@ -1,14 +1,17 @@
import { ClickHouse } from "@internal/clickhouse";
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import {
type TaskRunTemplate,
type RuntimeEnvironmentType,
type TaskRunStatus,
type TaskRunTemplate,
PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import parse from "parse-duration";
import { type PrismaClient } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { getTimezones } from "~/utils/timezones.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
import parse from "parse-duration";
export type RunTemplate = TaskRunTemplate & {
scheduledTaskPayload?: ScheduledRun["payload"];
@@ -20,6 +23,8 @@ type TestTaskOptions = {
environment: {
id: string;
type: RuntimeEnvironmentType;
projectId: string;
organizationId: string;
};
taskIdentifier: string;
};
@@ -111,11 +116,10 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
};
export class TestTaskPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
public async call({
userId,
@@ -128,7 +132,7 @@ export class TestTaskPresenter {
? (
await findCurrentWorkerDeployment({ environmentId: environment.id })
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
: await this.#prismaClient.backgroundWorkerTask.findFirst({
: await this.replica.backgroundWorkerTask.findFirst({
where: {
slug: taskIdentifier,
runtimeEnvironmentId: environment.id,
@@ -145,7 +149,7 @@ export class TestTaskPresenter {
}
const taskQueue = task.queueId
? await this.#prismaClient.taskQueue.findFirst({
? await this.replica.taskQueue.findFirst({
where: {
runtimeEnvironmentId: environment.id,
id: task.queueId,
@@ -159,7 +163,7 @@ export class TestTaskPresenter {
})
: undefined;
const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({
const backgroundWorkers = await this.replica.backgroundWorker.findMany({
where: {
runtimeEnvironmentId: environment.id,
},
@@ -173,7 +177,7 @@ export class TestTaskPresenter {
take: 20, // last 20 versions should suffice
});
const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({
const taskRunTemplates = await this.replica.taskRunTemplate.findMany({
where: {
projectId,
taskSlug: task.slug,
@@ -190,47 +194,55 @@ export class TestTaskPresenter {
const disableVersionSelection = environment.type === "DEVELOPMENT";
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
WITH taskruns AS (
SELECT
tr.*
FROM
${sqlDatabaseSchema}."TaskRun" as tr
JOIN
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
ON
tr."taskIdentifier" = bwt.slug
WHERE
bwt."friendlyId" = ${task.friendlyId} AND
tr."runtimeEnvironmentId" = ${environment.id}
ORDER BY
tr."createdAt" DESC
LIMIT 10
)
SELECT
taskr.id,
taskr."queue",
taskr."friendlyId",
taskr."taskIdentifier",
taskr."createdAt",
taskr.status,
taskr.payload,
taskr."payloadType",
taskr."seedMetadata",
taskr."seedMetadataType",
taskr."runtimeEnvironmentId",
taskr."concurrencyKey",
taskr."maxAttempts",
taskr."maxDurationInSeconds",
taskr."machinePreset",
taskr."ttl",
taskr."runTags"
FROM
taskruns AS taskr
WHERE
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
ORDER BY
taskr."createdAt" DESC;`;
// Get the latest runs, for the payloads
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica as PrismaClient,
});
const runIds = await runsRepository.listRunIds({
organizationId: environment.organizationId,
environmentId: environment.id,
projectId: environment.projectId,
tasks: [task.slug],
period: "30d",
page: {
size: 10,
},
});
const latestRuns = await this.replica.taskRun.findMany({
select: {
id: true,
queue: true,
friendlyId: true,
taskIdentifier: true,
createdAt: true,
status: true,
payload: true,
payloadType: true,
seedMetadata: true,
seedMetadataType: true,
runtimeEnvironmentId: true,
concurrencyKey: true,
maxAttempts: true,
maxDurationInSeconds: true,
machinePreset: true,
ttl: true,
runTags: true,
},
where: {
id: {
in: runIds,
},
payloadType: {
in: ["application/json", "application/super+json"],
},
},
orderBy: {
createdAt: "desc",
},
});
const taskWithEnvironment = {
id: task.id,
@@ -258,6 +270,12 @@ export class TestTaskPresenter {
async (r) =>
({
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: await prettyPrintPacket(r.payload, r.payloadType),
metadata: r.seedMetadata
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
@@ -300,6 +318,12 @@ export class TestTaskPresenter {
if (payload.success) {
return {
...r,
seedMetadata: r.seedMetadata ?? undefined,
seedMetadataType: r.seedMetadataType ?? undefined,
concurrencyKey: r.concurrencyKey ?? undefined,
maxAttempts: r.maxAttempts ?? undefined,
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
machinePreset: r.machinePreset ?? undefined,
payload: payload.data,
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
} satisfies ScheduledRun;
+7
View File
@@ -20,6 +20,13 @@ export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
};
export const headers = () => ({
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"Permissions-Policy":
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
});
export const meta: MetaFunction = ({ data }) => {
const typedData = data as UseDataFunctionReturn<typeof loader>;
return [
@@ -1,13 +1,9 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import {
redirectWithErrorMessage,
setRequestSuccessMessage,
commitSession,
} from "~/models/message.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { tryCatch } from "@trigger.dev/core";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
@@ -41,7 +37,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const callbackData = result.data;
@@ -54,7 +50,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
error: sessionResult.error,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
@@ -76,7 +72,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
switch (callbackData.setup_action) {
@@ -89,17 +85,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
const session = await setRequestSuccessMessage(request, "GitHub App installed successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
}
case "update": {
@@ -112,14 +101,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}
const session = await setRequestSuccessMessage(request, "GitHub App updated successfully");
session.flash("gitHubAppInstalled", true);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
}
case "request": {
@@ -129,17 +111,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
callbackData,
});
const session = await setRequestSuccessMessage(request, "GitHub App installation requested");
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
}
default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
}
@@ -0,0 +1,307 @@
import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { motion } from "framer-motion";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
const BatchParamSchema = EnvironmentParamSchema.extend({
batchParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, batchParam } =
BatchParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
try {
const presenter = new BatchPresenter();
const [error, data] = await tryCatch(
presenter.call({
environmentId: environment.id,
batchId: batchParam,
userId,
})
);
if (error) {
throw new Error(error.message);
}
return typedjson({ batch: data });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { batch } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Auto-reload when batch is still in progress
useAutoRevalidate({
interval: 1000,
onFocus: true,
disabled: batch.hasFinished,
});
const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
{/* Header */}
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
<LinkButton
to={v3BatchesPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Status bar */}
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BatchStatusCombo status={batch.status} />
<Paragraph variant="extra-small" className="text-text-dimmed">
{descriptionForBatchStatus(batch.status)}
</Paragraph>
</div>
{/* Scrollable content */}
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="space-y-3">
{/* Progress meter for v2 batches */}
{showProgressMeter && (
<div className="px-3 pt-3">
<BatchProgressMeter
successCount={batch.successfulRunCount}
failureCount={batch.failedRunCount}
totalCount={batch.runCount}
/>
</div>
)}
{/* Properties */}
<div className="px-3 py-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={batch.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<BatchStatusCombo status={batch.status} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total runs</Property.Label>
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
</Property.Item>
{batch.isV2 && (
<>
<Property.Item>
<Property.Label>Successfully created</Property.Label>
<Property.Value className="text-success">
{formatNumber(batch.successfulRunCount)}
</Property.Value>
</Property.Item>
{batch.failedRunCount > 0 && (
<Property.Item>
<Property.Label>Failed to create</Property.Label>
<Property.Value className="text-error">
{formatNumber(batch.failedRunCount)}
</Property.Value>
</Property.Item>
)}
</>
)}
{batch.idempotencyKey && (
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={batch.createdAt} />
</Property.Value>
</Property.Item>
{batch.processingStartedAt && (
<Property.Item>
<Property.Label>Processing started</Property.Label>
<Property.Value>
<DateTime date={batch.processingStartedAt} />
</Property.Value>
</Property.Item>
)}
{batch.processingCompletedAt && (
<Property.Item>
<Property.Label>Processing completed</Property.Label>
<Property.Value>
<DateTime date={batch.processingCompletedAt} />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Finished</Property.Label>
<Property.Value>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
{/* Errors section */}
{batch.errors.length > 0 && (
<div className="px-3 pb-3">
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
<ExclamationTriangleIcon className="size-4" />
Run creation errors ({batch.errors.length})
</Header3>
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-charcoal-900">
{batch.errors.map((error) => (
<div key={error.id} className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-text-dimmed">
Item #{error.index}
</span>
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
</div>
{error.errorCode && (
<span className="rounded bg-charcoal-750 px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
{error.errorCode}
</span>
)}
</div>
<Paragraph variant="small" className="mt-1 text-error">
{error.error}
</Paragraph>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<LinkButton
variant="tertiary/medium"
to={v3BatchRunsPath(organization, project, environment, batch)}
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
TrailingIcon={ArrowRightIcon}
>
View runs
</LinkButton>
</div>
</div>
);
}
type BatchProgressMeterProps = {
successCount: number;
failureCount: number;
totalCount: number;
};
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
const processedCount = successCount + failureCount;
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Run creation progress</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(processedCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-charcoal-900">
<motion.div
className="absolute left-0 top-0 h-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full bg-error"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
</div>
{failureCount > 0 && (
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-error" />
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
</div>
)}
</div>
</div>
);
}
@@ -1,10 +1,6 @@
import {
ArrowPathRoundedSquareIcon,
ArrowRightIcon,
ExclamationCircleIcon,
} from "@heroicons/react/20/solid";
import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -12,12 +8,15 @@ import { BatchesNone } from "~/components/BlankStatePanels";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
@@ -36,7 +35,6 @@ import {
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
@@ -44,13 +42,14 @@ import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type BatchList,
type BatchListItem,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
import {
docsPath,
EnvironmentParamSchema,
v3BatchPath,
v3BatchRunsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
@@ -101,6 +100,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export default function Page() {
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
useTypedLoaderData<typeof loader>();
const { batchParam } = useParams();
const isShowingInspector = batchParam !== undefined;
return (
<PageContainer>
@@ -123,22 +124,34 @@ export default function Page() {
<BatchesNone />
</MainCenteredContainer>
) : (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="batches-main" min={"100px"}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
hasAnyBatches={hasAnyBatches}
/>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
hasAnyBatches={hasAnyBatches}
/>
</div>
</ResizablePanel>
{isShowingInspector && (
<>
<ResizableHandle id="batches-handle" />
<ResizablePanel id="batches-inspector" min="100px" default="500px">
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
@@ -147,10 +160,14 @@ export default function Page() {
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const location = useLocation();
const isLoading =
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { batchParam } = useParams();
return (
<Table className="max-h-full overflow-y-auto">
@@ -195,15 +212,19 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
</div>
</TableBlankRow>
) : (
batches.map((batch, index) => {
const path = v3BatchRunsPath(organization, project, environment, batch);
batches.map((batch) => {
const basePath = v3BatchPath(organization, project, environment, batch);
const inspectorPath = `${basePath}${location.search}`;
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
const isSelected = batchParam === batch.friendlyId;
return (
<TableRow key={batch.id}>
<TableCell to={path} isTabbableCell>
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<TableCell to={inspectorPath} isTabbableCell>
{batch.friendlyId}
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
content="Upgrade to the latest SDK for batch statuses to appear."
@@ -223,8 +244,12 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
/>
)}
</TableCell>
<TableCell to={path}>{batch.runCount}</TableCell>
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
<TableCell
to={inspectorPath}
className="w-[1%]"
actionClassName="pr-0 tabular-nums"
>
{batch.finishedAt ? (
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
style: "short",
@@ -233,13 +258,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
<LiveTimer startTime={new Date(batch.createdAt)} />
)}
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
<DateTime date={batch.createdAt} />
</TableCell>
<TableCell to={path}>
<TableCell to={inspectorPath}>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</TableCell>
<BatchActionsCell batch={batch} path={path} />
<BatchActionsCell runsPath={runsPath} />
</TableRow>
);
})
@@ -257,48 +282,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
);
}
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
const location = useLocation();
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
return <TableCell to={path}>{""}</TableCell>;
}
function BatchActionsCell({ runsPath }: { runsPath: string }) {
return (
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
to={path}
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
title="View batch"
/>
{!batch.hasFinished && (
<Dialog>
<DialogTrigger
asChild
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
>
<Button
variant="small-menu-item"
LeadingIcon={ArrowPathRoundedSquareIcon}
leadingIconClassName="text-success"
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
>
Try and resume
</Button>
</DialogTrigger>
<CheckBatchCompletionDialog
batchId={batch.id}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
hiddenButtons={
<LinkButton to={runsPath} variant="minimal/small" LeadingIcon={ArrowRightIcon}>
View runs
</LinkButton>
}
/>
);
@@ -1,10 +1,9 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { Form, useRevalidator } from "@remix-run/react";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
@@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Error(error.message);
}
return typedjson({ bulkAction: data });
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({ bulkAction: data, autoReloadPollIntervalMs });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
};
export default function Page() {
const { bulkAction } = useTypedLoaderData<typeof loader>();
const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const disabled = bulkAction.status !== "PENDING";
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
{
event: "progress",
disabled,
}
);
const revalidation = useRevalidator();
useEffect(() => {
if (disabled || streamedEvents === null) {
return;
}
revalidation.revalidate();
}, [streamedEvents, disabled]);
useAutoRevalidate({
interval: autoReloadPollIntervalMs,
onFocus: true,
disabled: bulkAction.status !== "PENDING",
});
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
@@ -0,0 +1,808 @@
import { conform, useFieldList, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
EnvelopeIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
useActionData,
useNavigate,
useNavigation,
useSearchParams,
type MetaFunction,
} from "@remix-run/react";
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
ManageConcurrencyPresenter,
type ConcurrencyResult,
type EnvironmentWithConcurrency,
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
import { getPlans } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
import { logger } from "~/services/logger.server";
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
export const meta: MetaFunction = () => {
return [
{
title: `Manage concurrency | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const presenter = new ManageConcurrencyPresenter();
const [error, result] = await tryCatch(
presenter.call({
userId: userId,
projectId: project.id,
organizationId: project.organizationId,
})
);
if (error) {
throw new Response(undefined, {
status: 400,
statusText: error.message,
});
}
const plans = await tryCatch(getPlans());
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
return typedjson(result);
};
const FormSchema = z.discriminatedUnion("action", [
z.object({
action: z.enum(["purchase"]),
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.enum(["quota-increase"]),
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
}),
z.object({
action: z.enum(["allocate"]),
// It will only update environments that are passed in
environments: z.array(
z.object({
id: z.string(),
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
})
),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const redirectPath = concurrencyPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value || submission.intent !== "submit") {
return json(submission);
}
if (submission.value.action === "allocate") {
const allocate = new AllocateConcurrencyService();
const [error, result] = await tryCatch(
allocate.call({
userId,
projectId: project.id,
organizationId: project.organizationId,
environments: submission.value.environments,
})
);
if (error) {
submission.error.environments = [error instanceof Error ? error.message : "Unknown error"];
return json(submission);
}
if (!result.success) {
submission.error.environments = [result.error];
return json(submission);
}
return redirectWithSuccessMessage(
`${redirectPath}?success=true`,
request,
"Concurrency allocated successfully"
);
}
const service = new SetConcurrencyAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
projectId: project.id,
organizationId: project.organizationId,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
return json(submission);
}
if (!result.success) {
submission.error.amount = [result.error];
return json(submission);
}
return redirectWithSuccessMessage(
`${redirectPath}?success=true`,
request,
submission.value.action === "purchase"
? "Concurrency updated successfully"
: "Requested extra concurrency, we'll get back to you soon."
);
};
export default function Page() {
const {
canAddConcurrency,
extraConcurrency,
extraAllocatedConcurrency,
extraUnallocatedConcurrency,
environments,
concurrencyPricing,
maxQuota,
} = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Concurrency" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{environments.map((environment) => (
<Property.Item key={environment.id}>
<Property.Label>
{environment.type}{" "}
{environment.branchName ? ` (${environment.branchName})` : ""}
</Property.Label>
<Property.Value>{environment.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer>
{canAddConcurrency ? (
<Upgradable
canAddConcurrency={canAddConcurrency}
extraConcurrency={extraConcurrency}
extraAllocatedConcurrency={extraAllocatedConcurrency}
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
environments={environments}
concurrencyPricing={concurrencyPricing}
maxQuota={maxQuota}
/>
) : (
<NotUpgradable environments={environments} />
)}
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
function initialAllocation(environments: ConcurrencyResult["environments"]) {
return new Map<string, number>(
environments
.filter((e) => e.type !== "DEVELOPMENT")
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
);
}
function allocationTotal(environments: ConcurrencyResult["environments"]) {
const allocation = initialAllocation(environments);
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
}
function Upgradable({
extraConcurrency,
extraAllocatedConcurrency,
extraUnallocatedConcurrency,
environments,
concurrencyPricing,
maxQuota,
}: ConcurrencyResult) {
const lastSubmission = useActionData();
const [form, { environments: formEnvironments }] = useForm({
id: "purchase-concurrency",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
const navigation = useNavigation();
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
const [allocation, setAllocation] = useState(initialAllocation(environments));
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
const initialAllocationInProject = allocationTotal(environments);
const changeInAllocation = allocatedInProject - initialAllocationInProject;
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
const allocationModified = changeInAllocation !== 0;
return (
<div className="flex flex-col gap-3">
<div className="border-b border-grid-dimmed pb-1">
<Header2>Manage your concurrency</Header2>
</div>
<Paragraph variant="small">
Concurrency limits determine how many runs you can execute at the same time. You can add
extra concurrency to your organization which you can allocate to environments in your
projects.
</Paragraph>
<div className="mt-3 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center first-letter:pb-1">
<Header3 className="grow">Extra concurrency</Header3>
<PurchaseConcurrencyModal
concurrencyPricing={concurrencyPricing}
extraConcurrency={extraConcurrency}
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
maxQuota={maxQuota}
disabled={unallocated < 0 ? false : allocationModified}
/>
</div>
<Table>
<TableBody>
<TableRow>
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
<TableCell alignment="right" className="tabular-nums text-text-bright">
{extraConcurrency}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Allocated concurrency</TableCell>
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
{allocationModified ? (
<>
<span className="text-text-dimmed line-through">
{extraAllocatedConcurrency}
</span>{" "}
{extraAllocatedConcurrency + changeInAllocation}
</>
) : (
extraAllocatedConcurrency
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Unallocated concurrency</TableCell>
<TableCell
alignment="right"
className={cn(
"tabular-nums",
unallocated > 0
? "text-success"
: unallocated < 0
? "text-error"
: "text-text-bright"
)}
>
{allocationModified ? (
<>
<span className="text-text-dimmed line-through">
{extraUnallocatedConcurrency}
</span>{" "}
{extraUnallocatedConcurrency - changeInAllocation}
</>
) : (
extraUnallocatedConcurrency
)}
</TableCell>
</TableRow>
<TableRow className={allocationModified ? undefined : "after:bg-transparent"}>
<TableCell colSpan={2} className="py-0">
<div className="flex h-10 items-center">
{allocationModified ? (
unallocated < 0 ? (
<div className="flex items-center gap-1">
<ExclamationTriangleIcon className="size-4 text-error" />
<span className="text-error">
You're trying to allocate more concurrency than your total purchased
amount.
</span>
</div>
) : (
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1">
<InformationCircleIcon className="size-4 text-text-dimmed" />
<span>
Save your changes or{" "}
<button
className="inline text-indigo-500 hover:text-indigo-300"
onClick={() => {
setAllocation(initialAllocation(environments));
}}
>
reset
</button>
.
</span>
</div>
<Button
variant="primary/small"
type="submit"
form="allocate"
disabled={unallocated < 0 || isLoading}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
Save
</Button>
</div>
)
) : (
<></>
)}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<FormError id={formEnvironments.id}>{formEnvironments.error}</FormError>
</div>
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
<input type="hidden" name="action" value="allocate" />
<div className="flex items-center pb-1">
<Header3 className="grow">Concurrency allocation</Header3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
<TableHeaderCell alignment="right">
<span className="flex items-center justify-end gap-x-1">
Included{" "}
<InfoIconTooltip content="This is the included concurrency based on your plan." />
</span>
</TableHeaderCell>
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
<TableHeaderCell alignment="right">Total</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment, index) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentCombo environment={environment} />
</TableCell>
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
<TableCell alignment="right">
<div className="flex items-center justify-end">
{environment.type === "DEVELOPMENT" ? (
Math.max(
0,
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
)
) : (
<>
<input
type="hidden"
name={`environments[${index}].id`}
value={environment.id}
/>
<Input
name={`environments[${index}].amount`}
type="number"
variant="outline/small"
className="text-right"
containerClassName="w-16"
fullWidth={false}
value={allocation.get(environment.id)}
onChange={(e) => {
const value = e.target.value === "" ? 0 : Number(e.target.value);
setAllocation(new Map(allocation).set(environment.id, value));
}}
min={0}
/>
</>
)}
</div>
</TableCell>
<TableCell alignment="right">
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Form>
</div>
</div>
);
}
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
const { isManagedCloud } = useFeatures();
const plan = useCurrentPlan();
const organization = useOrganization();
return (
<div className="flex flex-col gap-3">
<div className="border-b border-grid-dimmed pb-1">
<Header2>Your concurrency</Header2>
</div>
{isManagedCloud ? (
<>
<Paragraph variant="small">
Concurrency limits determine how many runs you can execute at the same time. You can
upgrade your plan to get more concurrency. You are currently on the{" "}
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
</Paragraph>
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade for more concurrency
</LinkButton>
</>
) : null}
<div className="mt-3 flex flex-col gap-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell className="pl-0">
<EnvironmentCombo environment={environment} />
</TableCell>
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
function PurchaseConcurrencyModal({
concurrencyPricing,
extraConcurrency,
extraUnallocatedConcurrency,
maxQuota,
disabled,
}: {
concurrencyPricing: {
stepSize: number;
centsPerStep: number;
};
extraConcurrency: number;
extraUnallocatedConcurrency: number;
maxQuota: number;
disabled: boolean;
}) {
const lastSubmission = useActionData();
const [form, { amount }] = useForm({
id: "purchase-concurrency",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
const [amountValue, setAmountValue] = useState(extraConcurrency);
const navigation = useNavigation();
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
// Close the panel, when we've succeeded
// This is required because a redirect to the same path doesn't clear state
const [searchParams, setSearchParams] = useSearchParams();
const [open, setOpen] = useState(false);
useEffect(() => {
const success = searchParams.get("success");
if (success) {
setOpen(false);
setSearchParams((s) => {
s.delete("success");
return s;
});
}
}, [searchParams.get("success")]);
const state = updateState({
value: amountValue,
existingValue: extraConcurrency,
quota: maxQuota,
extraUnallocatedConcurrency,
});
const changeClassName =
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="primary/small"
disabled={disabled}
onClick={() => {
setOpen(true);
}}
>
{title}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<Form method="post" {...form.props}>
<div className="flex flex-col gap-4 pt-2">
<Paragraph variant="base/bright" spacing>
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
remove any extra concurrency after you have unallocated it from your environments
first.
</Paragraph>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra concurrency
</Label>
<InputNumberStepper
{...conform.input(amount, { type: "number" })}
step={concurrencyPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.error}</FormError>
<FormError>{form.error}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_increase_unallocated" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
You need to unallocate{" "}
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
concurrency from your environments in order to remove{" "}
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
Currently you can only have up to {maxQuota} extra concurrency. Send a request
below to lift your current limit. We'll get back to you soon.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
current total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(
(extraConcurrency * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({extraConcurrency / concurrencyPricing.stepSize} bundles)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraConcurrency)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency(
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "}
{formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(
(amountValue * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({amountValue / concurrencyPricing.stepSize} bundles)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
{`Send request for ${formatNumber(amountValue)}`}
</Button>
</>
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_increase_unallocated"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function updateState({
value,
existingValue,
quota,
extraUnallocatedConcurrency,
}: {
value: number;
existingValue: number;
quota: number;
extraUnallocatedConcurrency: number;
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
if (value === existingValue) return "no_change";
if (value < existingValue) {
const difference = existingValue - value;
if (difference > extraUnallocatedConcurrency) {
return "need_to_increase_unallocated";
}
return "decrease";
}
if (value > quota) return "above_quota";
return "increase";
}
@@ -1,10 +1,12 @@
import { Link, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEffect, useState, useRef, useCallback } from "react";
import { S2, S2Error } from "@s2-dev/streamstore";
import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp } from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
@@ -23,16 +25,22 @@ import {
} from "~/components/primitives/Table";
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -41,7 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new DeploymentPresenter();
const { deployment } = await presenter.call({
const { deployment, eventStream } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
@@ -49,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
deploymentShortCode: deploymentParam,
});
return typedjson({ deployment });
return typedjson({ deployment, eventStream });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -59,15 +67,122 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
type LogEntry = {
message: string;
timestamp: Date;
level: "info" | "error" | "warn" | "debug";
};
export default function Page() {
const { deployment } = useTypedLoaderData<typeof loader>();
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
const user = useUser();
const page = new URLSearchParams(location.search).get("page");
const logsDisabled = eventStream === undefined;
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isStreaming, setIsStreaming] = useState(true);
const [streamError, setStreamError] = useState<string | null>(null);
const isPending = deployment.status === "PENDING";
useEffect(() => {
if (logsDisabled) return;
const abortController = new AbortController();
setLogs([]);
setStreamError(null);
setIsStreaming(true);
const streamLogs = async () => {
try {
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
const basin = s2.basin(eventStream.s2.basin);
const stream = basin.stream(eventStream.s2.stream);
const readSession = await stream.readSession(
{
seq_num: 0,
wait: 60,
as: "bytes",
},
{ signal: abortController.signal }
);
const decoder = new TextDecoder();
for await (const record of readSession) {
const decoded = decoder.decode(record.body);
const result = DeploymentEventFromString.safeParse(decoded);
if (!result.success) {
// fallback to the previous format in s2 logs for compatibility
try {
const headers: Record<string, string> = {};
if (record.headers) {
for (const [nameBytes, valueBytes] of record.headers) {
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
}
}
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
setLogs((prevLogs) => [
...prevLogs,
{
timestamp: new Date(record.timestamp),
message: decoded,
level,
},
]);
} catch (err) {
console.error("Failed to parse log record:", err);
}
continue;
}
const event = result.data;
if (event.type !== "log") {
continue;
}
setLogs((prevLogs) => [
...prevLogs,
{
timestamp: new Date(record.timestamp),
message: event.data.message,
level: event.data.level,
},
]);
}
} catch (error) {
if (abortController.signal.aborted) return;
const isNotFoundError =
error instanceof S2Error &&
error.code &&
["permission_denied", "stream_not_found"].includes(error.code);
if (isNotFoundError) return;
console.error("Failed to stream logs:", error);
setStreamError("Failed to stream logs");
} finally {
if (!abortController.signal.aborted) {
setIsStreaming(false);
}
}
};
streamLogs();
return () => {
abortController.abort();
};
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
@@ -132,7 +247,11 @@ export default function Page() {
<Property.Label>Deploy</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{deployment.shortCode}</span>
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
{deployment.label && (
<Badge variant="extra-small" className="capitalize">
{deployment.label}
</Badge>
)}
</Property.Value>
</Property.Item>
<Property.Item>
@@ -155,6 +274,35 @@ export default function Page() {
/>
</Property.Value>
</Property.Item>
{!logsDisabled && (
<Property.Item>
<Property.Label>Logs</Property.Label>
<LogsDisplay
logs={logs}
isStreaming={isStreaming}
streamError={streamError}
initialCollapsed={(
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
).includes(deployment.status)}
/>
</Property.Item>
)}
{deployment.canceledAt && (
<Property.Item>
<Property.Label>Canceled at</Property.Label>
<Property.Value>
<>
<DateTimeAccurate date={deployment.canceledAt} /> UTC
</>
</Property.Value>
</Property.Item>
)}
{deployment.canceledReason && (
<Property.Item>
<Property.Label>Cancelation reason</Property.Label>
<Property.Value>{deployment.canceledReason}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Tasks</Property.Label>
<Property.Value>{deployment.tasks ? deployment.tasks.length : ""}</Property.Value>
@@ -197,6 +345,18 @@ export default function Page() {
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Installed at</Property.Label>
<Property.Value>
{deployment.installedAt ? (
<>
<DateTimeAccurate date={deployment.installedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Built at</Property.Label>
<Property.Value>
@@ -289,3 +449,189 @@ export default function Page() {
</div>
);
}
function LogsDisplay({
logs,
isStreaming,
streamError,
initialCollapsed = false,
}: {
logs: LogEntry[];
isStreaming: boolean;
streamError: string | null;
initialCollapsed?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const [collapsed, setCollapsed] = useState(initialCollapsed);
const logsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCollapsed(initialCollapsed);
}, [initialCollapsed]);
// auto-scroll log container to bottom when new logs arrive
useEffect(() => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [logs]);
const onCopyLogs = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const logsText = logs.map((log) => log.message).join("\n");
navigator.clipboard.writeText(logsText);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[logs]
);
const errorCount = logs.filter((log) => log.level === "error").length;
const warningCount = logs.filter((log) => log.level === "warn").length;
return (
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
errorCount > 0 ? "bg-error/80" : "bg-charcoal-600"
)}
/>
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
</Paragraph>
</div>
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
warningCount > 0 ? "bg-warning/80" : "bg-charcoal-600"
)}
/>
<Paragraph variant="extra-small/dimmed/mono">
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
</Paragraph>
</div>
</div>
{logs.length > 0 && (
<div className="flex items-center gap-3">
<TooltipProvider>
<Tooltip open={copied || mouseOver} disableHoverableContent>
<TooltipTrigger
onClick={onCopyLogs}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
)}
>
<div className="size-4 shrink-0">
{copied ? (
<ClipboardCheck className="size-full" />
) : (
<Clipboard className="size-full" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setCollapsed(!collapsed)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
"text-text-dimmed hover:text-text-bright"
)}
>
{collapsed ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{collapsed ? "Expand" : "Collapse"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div className="relative">
<div
ref={logsContainerRef}
className={cn(
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
collapsed ? "h-16" : "h-64"
)}
>
<div className="flex w-fit min-w-full flex-col">
{logs.length === 0 && (
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
{streamError ? (
<span className="text-error">Failed fetching logs</span>
) : (
<span className="text-text-dimmed">
{isStreaming ? "Waiting for logs..." : "No logs yet"}
</span>
)}
</div>
)}
{logs.map((log, index) => {
return (
<div
key={index}
className={cn(
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
log.level === "info" && "border-transparent hover:bg-charcoal-750"
)}
>
<span
className={cn(
"select-none whitespace-nowrap py-px",
log.level === "error" && "text-error/80",
log.level === "warn" && "text-warning/70",
log.level === "info" && "text-text-dimmed"
)}
>
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
</span>
<span
className={cn(
"whitespace-nowrap",
log.level === "error" && "text-error",
log.level === "warn" && "text-warning",
log.level === "info" && "text-text-bright"
)}
>
{log.message}
</span>
</div>
);
})}
</div>
</div>
{collapsed && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-charcoal-800/90 to-transparent" />
)}
</div>
</div>
);
}
@@ -1,5 +1,18 @@
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
Outlet,
useLocation,
useNavigate,
useNavigation,
useParams,
} from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
@@ -15,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -39,10 +60,6 @@ import {
deploymentStatusDescription,
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -61,6 +78,9 @@ import {
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { env } from "~/env.server";
import { DialogClose } from "@radix-ui/react-dialog";
export const meta: MetaFunction = () => {
return [
@@ -116,7 +136,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
? result.deployments.find((d) => d.version === version)
: undefined;
return typedjson({ ...result, selectedDeployment });
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -137,6 +159,7 @@ export default function Page() {
selectedDeployment,
connectedGithubRepository,
environmentGitHubBranch,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
@@ -144,6 +167,8 @@ export default function Page() {
const location = useLocation();
const navigate = useNavigate();
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
// If we have a selected deployment from the version param, show it
useEffect(() => {
if (selectedDeployment && !deploymentParam) {
@@ -334,11 +359,11 @@ export default function Page() {
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
@@ -347,7 +372,7 @@ export default function Page() {
{deploymentParam && (
<>
<ResizableHandle id="deployments-handle" />
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
<ResizablePanel id="deployments-inspector" min="500px" max="800px">
<Outlet />
</ResizablePanel>
</>
@@ -388,7 +413,10 @@ function DeploymentActionsCell({
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
if (!canBeRolledBack && !canBePromoted) {
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
const canBeCanceled = !finalStatuses.includes(deployment.status);
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
@@ -412,7 +440,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Rollback
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
@@ -432,7 +460,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Promote
Promote
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
@@ -442,8 +470,155 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canBeCanceled && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
>
Cancel
</Button>
</DialogTrigger>
<CancelDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
/>
);
}
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function CancelDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this deployment?</DialogHeader>
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Back</Button>
</DialogClose>
<Form action={formAction} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -1,4 +1,5 @@
import {
AdjustmentsHorizontalIcon,
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
@@ -8,19 +9,14 @@ import {
RectangleStackIcon,
} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
useNavigate,
useNavigation,
useRevalidator,
useSearchParams,
type MetaFunction,
} from "@remix-run/react";
import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
@@ -30,13 +26,16 @@ import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { BigNumber } from "~/components/metrics/BigNumber";
import { Badge } from "~/components/primitives/Badge";
import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons";
import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
@@ -55,25 +54,32 @@ import {
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useThrottle } from "~/hooks/useThrottle";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getUserById } from "~/models/user.server";
import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server";
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
import {
concurrencyPath,
docsPath,
EnvironmentParamSchema,
v3BillingPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { useThrottle } from "~/hooks/useThrottle";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
const SearchParamsSchema = z.object({
query: z.string().optional(),
@@ -121,9 +127,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const environmentQueuePresenter = new EnvironmentQueuePresenter();
const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS;
return typedjson({
...queues,
environment: await environmentQueuePresenter.call(environment),
autoReloadPollIntervalMs,
});
} catch (error) {
console.error(error);
@@ -211,34 +220,98 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
);
}
case "queue-override": {
const friendlyId = formData.get("friendlyId");
const concurrencyLimit = formData.get("concurrencyLimit");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
if (!concurrencyLimit) {
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
}
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
if (isNaN(limitNumber) || limitNumber < 0) {
return redirectWithErrorMessage(
redirectPath,
request,
"Concurrency limit must be a valid number"
);
}
const user = await getUserById(userId);
if (!user) {
return redirectWithErrorMessage(redirectPath, request, "User not found");
}
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
environment,
friendlyId.toString(),
limitNumber,
user
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to override queue concurrency limit"
);
}
return redirectWithSuccessMessage(
redirectPath,
request,
"Queue concurrency limit overridden"
);
}
case "queue-remove-override": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const result = await concurrencySystem.queues.resetConcurrencyLimit(
environment,
friendlyId.toString()
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to reset queue concurrency limit"
);
}
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
}
default:
return redirectWithErrorMessage(redirectPath, request, "Something went wrong");
}
};
export default function Page() {
const { environment, queues, success, pagination, code, totalQueues, hasFilters } =
useTypedLoaderData<typeof loader>();
const {
environment,
queues,
success,
pagination,
code,
totalQueues,
hasFilters,
autoReloadPollIntervalMs,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const env = useEnvironment();
const plan = useCurrentPlan();
// Reload the page periodically
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`,
{
event: "update",
}
);
const revalidation = useRevalidator();
useEffect(() => {
if (streamedEvents) {
revalidation.revalidate();
}
}, [streamedEvents]);
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
const limitStatus =
environment.running === environment.concurrencyLimit * environment.burstFactor
@@ -275,17 +348,19 @@ export default function Page() {
animate
accessory={
<div className="flex items-start gap-1">
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
<LinkButton
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
>
View runs
</LinkButton>
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
tooltip="View queued runs"
/>
</div>
}
valueClassName={env.paused ? "text-warning" : undefined}
@@ -308,15 +383,17 @@ export default function Page() {
}
accessory={
<LinkButton
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={RunsIcon}
leadingIconClassName="text-runs"
className="px-2"
to={v3RunsPath(organization, project, env, {
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
>
View runs
</LinkButton>
tooltip="View runs"
/>
}
compactThreshold={1000000}
/>
@@ -336,18 +413,14 @@ export default function Page() {
accessory={
plan ? (
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
<Feedback
button={
<Button
variant="tertiary/small"
LeadingIcon={ChatBubbleLeftEllipsisIcon}
leadingIconClassName="text-indigo-500"
>
Increase limit
</Button>
}
defaultValue="concurrency"
/>
<LinkButton
to={concurrencyPath(organization, project, env)}
variant="tertiary/small"
LeadingIcon={ConcurrencyIcon}
leadingIconClassName="text-amber-500"
>
Increase limit
</LinkButton>
) : (
<LinkButton
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
@@ -383,7 +456,8 @@ export default function Page() {
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
<TableHeaderCell alignment="right">Running/limit</TableHeaderCell>
<TableHeaderCell alignment="right">Running</TableHeaderCell>
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
<TableHeaderCell
alignment="right"
tooltip={
@@ -409,6 +483,17 @@ export default function Page() {
This queue is limited by a concurrency limit set in your code.
</Paragraph>
</div>
<div className="space-y-0.5">
<Header3>Override</Header3>
<Paragraph
variant="small"
className="!text-wrap text-text-dimmed"
spacing
>
This queue's concurrency limit has been manually overridden from the
dashboard or API.
</Paragraph>
</div>
</div>
}
>
@@ -423,7 +508,7 @@ export default function Page() {
{queues.length > 0 ? (
queues.map((queue) => {
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
const isAtLimit = queue.running === limit;
const isAtLimit = queue.running >= limit;
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
queue.name
}`;
@@ -459,6 +544,18 @@ export default function Page() {
<span className={queue.paused ? "opacity-50" : undefined}>
{queue.name}
</span>
{queue.concurrency?.overriddenAt ? (
<SimpleTooltip
button={
<Badge variant="extra-small" className="text-text-bright">
Concurrency limit overridden
</Badge>
}
content="This queue's concurrency limit has been manually overridden from the dashboard or API."
className="max-w-xs"
disableHoverableContent
/>
) : null}
{queue.paused ? (
<Badge variant="extra-small" className="text-warning">
Paused
@@ -473,31 +570,50 @@ export default function Page() {
</TableCell>
<TableCell
alignment="right"
className={queue.paused ? "opacity-50" : undefined}
className={cn(
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined
)}
>
{queue.queued}
</TableCell>
<TableCell
alignment="right"
className={cn(
queue.paused ? "tabular-nums opacity-50" : undefined,
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined,
queue.running > 0 && "text-text-bright",
isAtLimit && "text-warning"
)}
>
{queue.running}/
<span className={cn("tabular-nums", isAtLimit && "text-warning")}>
{limit}
</span>
{queue.running}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%] tabular-nums",
queue.paused ? "opacity-50" : undefined,
isAtLimit && "text-warning"
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{queue.concurrencyLimit ? "User" : "Environment"}
{limit}
</TableCell>
<TableCell
alignment="right"
className={cn(
"w-[1%]",
queue.paused ? "opacity-50" : undefined,
isAtLimit && "text-warning",
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
)}
>
{queue.concurrency?.overriddenAt ? (
<span className="text-text-bright">Override</span>
) : queue.concurrencyLimit ? (
"User"
) : (
"Environment"
)}
</TableCell>
<TableCellMenu
isSticky
@@ -524,50 +640,43 @@ export default function Page() {
showTooltip={false}
/>
)}
<LinkButton
variant="minimal/small"
<PopoverMenuItem
icon={RunsIcon}
leadingIconClassName="text-runs"
title="View all runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
>
View all runs
</LinkButton>
<LinkButton
variant="minimal/small"
/>
<PopoverMenuItem
icon={RectangleStackIcon}
leadingIconClassName="text-queues"
title="View queued runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["PENDING"],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={RectangleStackIcon}
leadingIconClassName="text-queues"
>
View queued runs
</LinkButton>
<LinkButton
variant="minimal/small"
/>
<PopoverMenuItem
icon={Spinner}
leadingIconClassName="text-queues animate-none"
title="View running runs"
to={v3RunsPath(organization, project, env, {
queues: [queueFilterableName],
statuses: ["DEQUEUED", "EXECUTING"],
period: "30d",
rootOnly: false,
})}
fullWidth
textAlignLeft
LeadingIcon={Spinner}
leadingIconClassName="size-4 animate-none"
>
View running runs
</LinkButton>
/>
<QueueOverrideConcurrencyButton
queue={queue}
environmentConcurrencyLimit={environment.concurrencyLimit}
/>
</>
}
/>
@@ -576,7 +685,7 @@ export default function Page() {
})
) : (
<TableRow>
<TableCell colSpan={6}>
<TableCell colSpan={7}>
<div className="grid place-items-center py-6 text-text-dimmed">
<Paragraph>
{hasFilters
@@ -733,29 +842,26 @@ function QueuePauseResumeButton({
fullWidth?: boolean;
showTooltip?: boolean;
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const button = (
<Button
type="button"
variant={variant}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
>
{queue.paused ? "Resume..." : "Pause..."}
</Button>
);
const trigger = showTooltip ? (
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogTrigger asChild>
<Button
type="button"
variant={variant}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
>
{queue.paused ? "Resume..." : "Pause..."}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
@@ -767,7 +873,13 @@ function QueuePauseResumeButton({
</TooltipProvider>
</div>
) : (
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogTrigger asChild>
<PopoverMenuItem
icon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
title={queue.paused ? "Resume..." : "Pause..."}
/>
</DialogTrigger>
);
return (
@@ -814,6 +926,124 @@ function QueuePauseResumeButton({
);
}
function QueueOverrideConcurrencyButton({
queue,
environmentConcurrencyLimit,
}: {
queue: QueueItem;
environmentConcurrencyLimit: number;
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
);
const isOverridden = !!queue.concurrency?.overriddenAt;
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
useEffect(() => {
if (navigation.state === "loading" || navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
const isLoading = Boolean(
navigation.formData?.get("action") === "queue-override" ||
navigation.formData?.get("action") === "queue-remove-override"
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<PopoverMenuItem
icon={AdjustmentsHorizontalIcon}
title={isOverridden ? "Edit override…" : "Override limit…"}
/>
</DialogTrigger>
<DialogContent>
<DialogHeader>
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
{isOverridden ? (
<Paragraph>
This queue's concurrency limit is currently overridden to {currentLimit}.
{typeof queue.concurrency?.base === "number" &&
` The original limit set in code was ${queue.concurrency.base}.`}{" "}
You can update the override or remove it to restore the{" "}
{typeof queue.concurrency?.base === "number"
? "limit set in code"
: "environment concurrency limit"}
.
</Paragraph>
) : (
<Paragraph>
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
</Paragraph>
)}
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
<input type="hidden" name="friendlyId" value={queue.id} />
<div className="space-y-2">
<label htmlFor="concurrencyLimit" className="text-sm text-text-bright">
Concurrency limit
</label>
<Input
type="number"
name="concurrencyLimit"
id="concurrencyLimit"
min="0"
max={environmentConcurrencyLimit}
value={concurrencyLimit}
onChange={(e) => setConcurrencyLimit(e.target.value)}
placeholder={currentLimit.toString()}
autoFocus
/>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="queue-override"
disabled={isLoading || !concurrencyLimit}
variant="primary/medium"
LeadingIcon={isLoading && <Spinner color="white" />}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isOverridden ? "Update override" : "Override limit"}
</Button>
}
cancelButton={
<div className="flex items-center justify-between gap-2">
{isOverridden && (
<Button
type="submit"
name="action"
value="queue-remove-override"
disabled={isLoading}
variant="danger/medium"
>
Remove override
</Button>
)}
<DialogClose asChild>
<Button type="button" variant="tertiary/medium">
Cancel
</Button>
</DialogClose>
</div>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function EngineVersionUpgradeCallout() {
return (
<div className="mt-4 flex max-w-lg flex-col gap-4 rounded-sm border border-grid-bright bg-background-bright px-4">
@@ -2,6 +2,7 @@ import {
ArrowUturnLeftIcon,
BoltSlashIcon,
BookOpenIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronRightIcon,
InformationCircleIcon,
@@ -68,7 +69,6 @@ import {
eventBorderClassName,
} from "~/components/runs/v3/SpanTitle";
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
import { env } from "~/env.server";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
@@ -88,6 +88,7 @@ import {
docsPath,
v3BillingPath,
v3RunParamsSchema,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
v3RunStreamingPath,
@@ -97,6 +98,14 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { useSearchParams } from "~/hooks/useSearchParam";
import { CopyableText } from "~/components/primitives/CopyableText";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
const resizableSettings = {
parent: {
@@ -130,6 +139,103 @@ const resizableSettings = {
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
type RunsListNavigation = {
runs: Array<{ friendlyId: string; spanId: string }>;
pagination: { next?: string; previous?: string };
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
};
async function getRunsListFromTableState({
tableStateParam,
organizationSlug,
projectParam,
envParam,
runParam,
userId,
}: {
tableStateParam: string | null;
organizationSlug: string;
projectParam: string;
envParam: string;
runParam: string;
userId: string;
}): Promise<RunsListNavigation | null> {
if (!tableStateParam) {
return null;
}
try {
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
if (!project || !environment) {
return null;
}
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
pageSize: 25, // Load enough runs to provide navigation context
});
const runsList: RunsListNavigation = {
runs: currentPageResult.runs,
pagination: currentPageResult.pagination,
};
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.previous,
direction: "backward",
pageSize: 1, // We only need the last run from the previous page
});
if (prevPageResult.runs.length > 0) {
runsList.prevPageLastRun = {
friendlyId: prevPageResult.runs[0].friendlyId,
spanId: prevPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.previous,
};
}
}
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
cursor: currentPageResult.pagination.next,
direction: "forward",
pageSize: 1, // We only need the first run from the next page
});
if (nextPageResult.runs.length > 0) {
runsList.nextPageFirstRun = {
friendlyId: nextPageResult.runs[0].friendlyId,
spanId: nextPageResult.runs[0].spanId,
cursor: currentPageResult.pagination.next,
};
}
}
return runsList;
} catch (error) {
logger.error("Error loading runs list from tableState:", { error });
return null;
}
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -142,7 +248,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [error, result] = await tryCatch(
presenter.call({
userId,
organizationSlug,
showDeletedLogs: !!impersonationId,
projectSlug: projectParam,
runFriendlyId: runParam,
@@ -169,21 +274,31 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
const runsList = await getRunsListFromTableState({
tableStateParam: url.searchParams.get("tableState"),
organizationSlug,
projectParam,
envParam,
runParam,
userId,
});
return json({
run: result.run,
trace: result.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
resizable: {
parent,
tree,
},
runsList,
});
};
type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -191,16 +306,30 @@ export default function Page() {
logCount: trace?.events.length ?? 0,
isCompleted: run.completedAt !== null,
});
const { value } = useSearchParams();
const tableState = decodeURIComponent(value("tableState") ?? "");
const tableStateSearchParams = new URLSearchParams(tableState);
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
const tabParam = value("tab") ?? undefined;
const spanParam = value("span") ?? undefined;
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project, environment),
to: v3RunsPath(organization, project, environment, filters),
text: "Runs",
}}
title={<CopyableText value={run.friendlyId} />}
title={<>
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
{tableState && (<div className="flex">
<PreviousRunButton to={previousRunPath} />
<NextRunButton to={nextRunPath} />
</div>)}
</>}
/>
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
<PageAccessories>
@@ -276,14 +405,10 @@ export default function Page() {
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
) : (
<NoLogsView
run={run}
trace={trace}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
resizable={resizable}
/>
)}
</PageBody>
@@ -291,7 +416,7 @@ export default function Page() {
);
}
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -302,7 +427,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
return <></>;
}
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace;
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
@@ -324,6 +450,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
return (
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
<ResizablePanelGroup
@@ -372,6 +500,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
<SpanView
runParam={run.friendlyId}
spanId={selectedSpanId}
spanOverrides={spanOverrides as SpanOverride | undefined}
closePanel={() => replaceSearchParam("span")}
/>
</ResizablePanel>
@@ -381,7 +510,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
);
}
function NoLogsView({ run, resizable }: LoaderData) {
function NoLogsView({ run }: Pick<LoaderData, "run">) {
const plan = useCurrentPlan();
const organization = useOrganization();
@@ -815,7 +944,6 @@ function TimelineView({
scale,
rootSpanStatus,
rootStartedAt,
parentRef,
timelineScrollRef,
virtualizer,
events,
@@ -831,6 +959,7 @@ function TimelineView({
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
const minTimelineWidth = initialTimelineDimensions?.width ?? 300;
const maxTimelineWidth = minTimelineWidth * 10;
const disableSpansAnimations = rootSpanStatus !== "executing";
//we want to live-update the duration if the root span is still executing
const [duration, setDuration] = useState(queueAdjustedNs(totalDuration, queuedDuration));
@@ -1002,7 +1131,8 @@ function TimelineView({
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
eventBackgroundClassName(node.data)
)}
layoutId={`${node.id}-${event.name}`}
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1020,7 +1150,8 @@ function TimelineView({
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
eventBorderClassName(node.data)
)}
layoutId={`${node.id}-${event.name}`}
layoutId={disableSpansAnimations ? undefined : `${node.id}-${event.name}`}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1039,7 +1170,8 @@ function TimelineView({
>
<motion.div
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
layoutId={`mark-${node.id}`}
layoutId={disableSpansAnimations ? undefined : `mark-${node.id}`}
animate={disableSpansAnimations ? false : undefined}
/>
</Timeline.Span>
) : null}
@@ -1062,6 +1194,7 @@ function TimelineView({
}
node={node}
fadeLeft={isTopSpan && queuedDuration !== undefined}
disableAnimations={disableSpansAnimations}
/>
</>
) : (
@@ -1076,7 +1209,8 @@ function TimelineView({
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
eventBackgroundClassName(node.data)
)}
layoutId={node.id}
layoutId={disableSpansAnimations ? undefined : node.id}
animate={disableSpansAnimations ? false : undefined}
/>
)}
</Timeline.Point>
@@ -1308,8 +1442,9 @@ function SpanWithDuration({
showDuration,
node,
fadeLeft,
disableAnimations,
...props
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean }) {
}: Timeline.SpanProps & { node: TraceEvent; showDuration: boolean; fadeLeft: boolean; disableAnimations?: boolean }) {
return (
<Timeline.Span {...props}>
<motion.div
@@ -1319,7 +1454,8 @@ function SpanWithDuration({
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
)}
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
layoutId={node.id}
layoutId={disableAnimations ? undefined : node.id}
animate={disableAnimations ? false : undefined}
>
{node.data.isPartial && (
<div
@@ -1332,10 +1468,12 @@ function SpanWithDuration({
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
!showDuration && "opacity-0"
)}
animate={disableAnimations ? false : undefined}
>
<motion.div
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
layout="position"
layout={disableAnimations ? undefined : "position"}
animate={disableAnimations ? false : undefined}
>
{formatDurationMilliseconds(props.durationMs, {
style: "short",
@@ -1418,16 +1556,16 @@ function KeyboardShortcuts({
expandAllBelowDepth,
collapseAllBelowDepth,
toggleExpandLevel,
setShowDurations,
}: {
expandAllBelowDepth: (depth: number) => void;
collapseAllBelowDepth: (depth: number) => void;
toggleExpandLevel: (depth: number) => void;
setShowDurations: (show: (show: boolean) => boolean) => void;
setShowDurations?: (show: (show: boolean) => boolean) => void;
}) {
return (
<>
<ArrowKeyShortcuts />
<AdjacentRunsShortcuts />
<ShortcutWithAction
shortcut={{ key: "e" }}
action={() => expandAllBelowDepth(0)}
@@ -1444,6 +1582,16 @@ function KeyboardShortcuts({
);
}
function AdjacentRunsShortcuts() {
return (<div className="flex items-center gap-0.5">
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Adjacent runs
</Paragraph>
</div>);
}
function ArrowKeyShortcuts() {
return (
<div className="flex items-center gap-0.5">
@@ -1490,7 +1638,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
return (
<div className="flex items-center gap-0.5">
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
<span className="text-[0.75rem] text-text-dimmed"></span>
<span className="text-[0.65rem] text-text-dimmed"></span>
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
Toggle level
@@ -1522,3 +1670,127 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
/>
);
}
function useAdjacentRunPaths({
organization,
project,
environment,
tableState,
run,
runsList,
tabParam,
useSpan
}: {
organization: { slug: string };
project: { slug: string };
environment: { slug: string };
tableState: string;
run: { friendlyId: string, spanId: string };
runsList: RunsListNavigation | null;
tabParam?: string;
useSpan?: boolean;
}): [string | null, string | null] {
if (!runsList || runsList.runs.length === 0) {
return [null, null];
}
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
if (currentIndex === -1) {
return [null, null];
}
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
let previousRun: { friendlyId: string; spanId: string } | null = null;
const previousRunTableState = new URLSearchParams(tableState);
if (currentIndex > 0) {
previousRun = runsList.runs[currentIndex - 1];
} else if (runsList.prevPageLastRun) {
previousRun = runsList.prevPageLastRun;
// Update tableState with the new cursor for the previous page
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
previousRunTableState.set("direction", "backward");
}
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
let nextRun: { friendlyId: string; spanId: string } | null = null;
const nextRunTableState = new URLSearchParams(tableState);
if (currentIndex < runsList.runs.length - 1) {
nextRun = runsList.runs[currentIndex + 1];
} else if (runsList.nextPageFirstRun) {
nextRun = runsList.nextPageFirstRun;
// Update tableState with the new cursor for the next page
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
nextRunTableState.set("direction", "forward");
}
const previousURLSearchParams = new URLSearchParams();
previousURLSearchParams.set("tableState", previousRunTableState.toString());
if (previousRun && useSpan) {
previousURLSearchParams.set("span", previousRun.spanId);
}
if (tabParam && useSpan) {
previousURLSearchParams.set("tab", tabParam);
}
const previousRunPath = previousRun
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
: null;
const nextURLSearchParams = new URLSearchParams();
nextURLSearchParams.set("tableState", nextRunTableState.toString());
if (nextRun && useSpan) {
nextURLSearchParams.set("span", nextRun.spanId);
}
if (tabParam && useSpan) {
nextURLSearchParams.set("tab", tabParam);
}
const nextRunPath = nextRun
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
: null;
return [previousRunPath, nextRunPath];
}
function PreviousRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
LeadingIcon={ChevronUpIcon}
className={cn(
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "[" }}
tooltip="Previous Run"
disabled={!to}
replace
/>
</div>
);
}
function NextRunButton({ to }: { to: string | null }) {
return (
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
<LinkButton
to={to ? to : '#'}
variant={"minimal/small"}
TrailingIcon={ChevronDownIcon}
className={cn(
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
!to && "cursor-not-allowed opacity-50"
)}
onClick={(e) => !to && e.preventDefault()}
shortcut={{ key: "]" }}
tooltip="Next Run"
disabled={!to}
replace
/>
</div>
);
}
@@ -55,6 +55,7 @@ import {
v3CreateBulkActionPath,
v3ProjectPath,
v3TestPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
@@ -235,7 +236,13 @@ function RunsList({
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions />
<RunTaskInstructions
task={
list.filters.tasks.length === 1
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
: undefined
}
/>
)
) : (
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
@@ -291,6 +298,7 @@ function RunsList({
runs={list.runs}
isLoading={isLoading}
allowSelection
rootOnlyDefault={rootOnlyDefault}
/>
</div>
)}
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
);
}
function RunTaskInstructions() {
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
Perform a test run with a payload directly from the dashboard.
</Paragraph>
<LinkButton
to={v3TestPath(organization, project, environment)}
to={
task
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
: v3TestPath(organization, project, environment)
}
variant="secondary/medium"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-lime-500"
@@ -318,6 +318,7 @@ export default function Page() {
runs={schedule.runs}
isLoading={false}
variant="bright"
disableAdjacentRows
/>
</div>
<div className="flex flex-col gap-1 pt-2">
@@ -1,34 +1,19 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
FolderIcon,
TrashIcon,
LockClosedIcon,
PlusIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
useActionData,
useNavigation,
useNavigate,
} from "@remix-run/react";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { OctoKitty } from "~/components/GitHubLoginButton";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
@@ -49,35 +34,16 @@ import {
redirectBackWithSuccessMessage,
redirectWithErrorMessage,
redirectWithSuccessMessage,
getSession,
commitSession,
} from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import {
organizationPath,
v3ProjectPath,
githubAppInstallPath,
EnvironmentParamSchema,
v3ProjectSettingsPath,
} from "~/utils/pathBuilder";
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import React, { useEffect, useState } from "react";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { type BranchTrackingConfig } from "~/v3/github";
import {
EnvironmentIcon,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { GitBranchIcon } from "lucide-react";
import { useEnvironment } from "~/hooks/useEnvironment";
import { DateTime } from "~/components/primitives/DateTime";
import { TextLink } from "~/components/primitives/TextLink";
import { cn } from "~/utils/cn";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { type BuildSettings } from "~/v3/buildSettings";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
export const meta: MetaFunction = () => {
return [
@@ -123,40 +89,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { gitHubApp, buildSettings } = resultOrFail.value;
const session = await getSession(request.headers.get("Cookie"));
const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true;
const headers = new Headers({
"Set-Cookie": await commitSession(session),
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
});
return typedjson(
{
githubAppEnabled: gitHubApp.enabled,
githubAppInstallations: gitHubApp.installations,
connectedGithubRepository: gitHubApp.connectedRepository,
openGitHubRepoConnectionModal,
buildSettings,
},
{ headers }
);
};
const ConnectGitHubRepoFormSchema = z.object({
action: z.literal("connect-repo"),
installationId: z.string(),
repositoryId: z.string(),
});
const UpdateGitSettingsFormSchema = z.object({
action: z.literal("update-git-settings"),
productionBranch: z.string().trim().optional(),
stagingBranch: z.string().trim().optional(),
previewDeploymentsEnabled: z
.string()
.optional()
.transform((val) => val === "on"),
});
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
@@ -167,14 +105,6 @@ const UpdateBuildSettingsFormSchema = z.object({
.refine((val) => !val || val.length <= 255, {
message: "Config file path must not exceed 255 characters",
}),
installDirectory: z
.string()
.trim()
.optional()
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
.refine((val) => !val || val.length <= 255, {
message: "Install directory must not exceed 255 characters",
}),
installCommand: z
.string()
.trim()
@@ -185,6 +115,20 @@ const UpdateBuildSettingsFormSchema = z.object({
.refine((val) => !val || val.length <= 500, {
message: "Install command must not exceed 500 characters",
}),
preBuildCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Pre-build command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Pre-build command must not exceed 500 characters",
}),
useNativeBuildServer: z
.string()
.optional()
.transform((val) => val === "on"),
});
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
@@ -220,12 +164,7 @@ export function createSchema(
}
}),
}),
ConnectGitHubRepoFormSchema,
UpdateGitSettingsFormSchema,
UpdateBuildSettingsFormSchema,
z.object({
action: z.literal("disconnect-repo"),
}),
]);
}
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId, organizationId } = membershipResultOrFail.value;
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
@@ -316,108 +255,15 @@ export const action: ActionFunction = async ({ request, params }) => {
"Project deleted"
);
}
case "disconnect-repo": {
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to disconnect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
}
}
}
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
}
case "update-git-settings": {
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
const resultOrFail = await projectSettingsService.updateGitSettings(
projectId,
productionBranch,
stagingBranch,
previewDeploymentsEnabled
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "github_app_not_enabled": {
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
}
case "connected_gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
}
case "production_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
}
case "staging_tracking_branch_not_found": {
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update Git settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
}
case "connect-repo": {
const { repositoryId, installationId } = submission.value;
const resultOrFail = await projectSettingsService.connectGitHubRepo(
projectId,
organizationId,
repositoryId,
installationId
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "gh_repository_not_found": {
return redirectBackWithErrorMessage(request, "GitHub repository not found");
}
case "project_already_has_connected_repository": {
return redirectBackWithErrorMessage(
request,
"Project already has a connected repository"
);
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to connect GitHub repository", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
}
}
}
return json({
...submission,
success: true,
});
}
case "update-build-settings": {
const { installDirectory, installCommand, triggerConfigFilePath } = submission.value;
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
installDirectory: installDirectory || undefined,
installCommand: installCommand || undefined,
preBuildCommand: preBuildCommand || undefined,
triggerConfigFilePath: triggerConfigFilePath || undefined,
useNativeBuildServer: useNativeBuildServer,
});
if (resultOrFail.isErr()) {
@@ -444,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export default function Page() {
const {
githubAppInstallations,
connectedGithubRepository,
githubAppEnabled,
openGitHubRepoConnectionModal,
buildSettings,
} = useTypedLoaderData<typeof loader>();
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
@@ -576,17 +416,12 @@ export default function Page() {
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
{connectedGithubRepository ? (
<ConnectedGitHubRepoForm connectedGitHubRepo={connectedGithubRepository} />
) : (
<GitHubConnectionPrompt
gitHubAppInstallations={githubAppInstallations ?? []}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
openGitHubRepoConnectionModal={openGitHubRepoConnectionModal}
/>
)}
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
@@ -646,480 +481,24 @@ export default function Page() {
);
}
type GitHubRepository = {
id: string;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
};
type GitHubAppInstallation = {
id: string;
appInstallationId: bigint;
targetType: string;
accountHandle: string;
repositories: GitHubRepository[];
};
function ConnectGitHubRepoModal({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
open = false,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
open?: boolean;
}) {
const [isModalOpen, setIsModalOpen] = useState(open);
const lastSubmission = useActionData() as any;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<
GitHubAppInstallation | undefined
>(gitHubAppInstallations.at(0));
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
undefined
);
const navigation = useNavigation();
const isConnectRepositoryLoading =
navigation.formData?.get("action") === "connect-repo" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [form, { installationId, repositoryId }] = useForm({
id: "connect-repo",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: ConnectGitHubRepoFormSchema,
});
},
});
useEffect(() => {
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
setIsModalOpen(false);
}
}, [lastSubmission]);
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
Connect GitHub repo
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Connect GitHub repository</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...form.props} className="w-full">
<Paragraph className="mb-3">
Choose a GitHub repository to connect to your project.
</Paragraph>
<Fieldset className="max-w-full gap-y-3">
<InputGroup className="max-w-full">
<Label htmlFor={installationId.id}>Account</Label>
<Select
name={installationId.name}
id={installationId.id}
value={selectedInstallation?.id}
defaultValue={gitHubAppInstallations.at(0)?.id}
setValue={(value) => {
if (Array.isArray(value)) return;
const installation = gitHubAppInstallations.find((i) => i.id === value);
setSelectedInstallation(installation);
setSelectedRepository(undefined);
}}
items={gitHubAppInstallations}
variant="tertiary/small"
placeholder="Select account"
dropdownIcon
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
>
{[
...gitHubAppInstallations.map((installation) => (
<SelectItem
key={installation.id}
value={installation.id}
icon={<OctoKitty className="size-3 text-text-dimmed" />}
>
{installation.accountHandle}
</SelectItem>
)),
<SelectItem
onClick={(e) => {
e.preventDefault();
navigate(
githubAppInstallPath(
organizationSlug,
v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)
)
);
}}
key="new-account"
icon={<PlusIcon className="size-3 text-text-dimmed" />}
>
Add account
</SelectItem>,
]}
</Select>
<FormError id={installationId.errorId}>{installationId.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
<Label htmlFor={repositoryId.id}>Repository</Label>
<Select
name={repositoryId.name}
id={repositoryId.id}
value={selectedRepository ? selectedRepository.id : undefined}
setValue={(value) => {
if (Array.isArray(value)) return;
const repository = selectedInstallation?.repositories.find(
(r) => r.id === value
);
setSelectedRepository(repository);
}}
variant="tertiary/small"
placeholder="Select repository"
heading="Filter repositories"
dropdownIcon
items={selectedInstallation?.repositories ?? []}
filter={{ keys: ["name"] }}
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
text={selectedRepository ? selectedRepository.name : null}
>
{(matches) =>
matches.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<div className="flex items-center gap-1">
{repo.name}
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
</div>
</SelectItem>
))
}
</Select>
<Hint className={cn("invisible", selectedInstallation && "visible")}>
Configure repository access in{" "}
<TextLink
target="_blank"
rel="noreferrer noopener"
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
>
GitHub
</TextLink>
.
</Hint>
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="connect-repo"
variant="primary/medium"
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
leadingIconClassName="text-white"
disabled={isConnectRepositoryLoading}
>
Connect repository
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
function GitHubConnectionPrompt({
gitHubAppInstallations,
organizationSlug,
projectSlug,
environmentSlug,
openGitHubRepoConnectionModal = false,
}: {
gitHubAppInstallations: GitHubAppInstallation[];
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
openGitHubRepoConnectionModal?: boolean;
}) {
return (
<Fieldset>
<InputGroup fullWidth>
{gitHubAppInstallations.length === 0 && (
<LinkButton
to={githubAppInstallPath(
organizationSlug,
v3ProjectSettingsPath(
{ slug: organizationSlug },
{ slug: projectSlug },
{ slug: environmentSlug }
)
)}
variant={"secondary/medium"}
LeadingIcon={OctoKitty}
>
Install GitHub app
</LinkButton>
)}
{gitHubAppInstallations.length !== 0 && (
<div className="flex items-center gap-3">
<ConnectGitHubRepoModal
gitHubAppInstallations={gitHubAppInstallations}
organizationSlug={organizationSlug}
projectSlug={projectSlug}
environmentSlug={environmentSlug}
open={openGitHubRepoConnectionModal}
/>
<span className="flex items-center gap-1 text-xs text-text-dimmed">
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
</span>
</div>
)}
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
</InputGroup>
</Fieldset>
);
}
type ConnectedGitHubRepo = {
branchTracking: BranchTrackingConfig | undefined;
previewDeploymentsEnabled: boolean;
createdAt: Date;
repository: GitHubRepository;
};
function ConnectedGitHubRepoForm({
connectedGitHubRepo,
}: {
connectedGitHubRepo: ConnectedGitHubRepo;
}) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
const [gitSettingsValues, setGitSettingsValues] = useState({
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
});
useEffect(() => {
const hasChanges =
gitSettingsValues.productionBranch !==
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
gitSettingsValues.stagingBranch !==
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
setHasGitSettingsChanges(hasChanges);
}, [gitSettingsValues, connectedGitHubRepo]);
const [gitSettingsForm, fields] = useForm({
id: "update-git-settings",
lastSubmission: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parse(formData, {
schema: UpdateGitSettingsFormSchema,
});
},
});
const isGitSettingsLoading =
navigation.formData?.get("action") === "update-git-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<>
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
<div className="flex items-center gap-2">
<OctoKitty className="size-4" />
<a
href={connectedGitHubRepo.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-bright hover:underline"
>
{connectedGitHubRepo.repository.fullName}
</a>
{connectedGitHubRepo.repository.private && (
<LockClosedIcon className="size-3 text-text-dimmed" />
)}
<span className="text-xs text-text-dimmed">
<DateTime
date={connectedGitHubRepo.createdAt}
includeTime={false}
includeSeconds={false}
showTimezone={false}
showTooltip={false}
/>
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="minimal/small">Disconnect</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>Disconnect GitHub repository</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph className="mb-1">
Are you sure you want to disconnect{" "}
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
This will stop automatic deployments from GitHub.
</Paragraph>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="action" value="disconnect-repo" />
<Button type="submit" variant="danger/medium">
Disconnect repository
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
</div>
<Form method="post" {...gitSettingsForm.props}>
<Fieldset>
<InputGroup fullWidth>
<Hint>
Every commit on the selected tracking branch creates a deployment in the corresponding
environment.
</Hint>
<div className="grid grid-cols-[120px_1fr] gap-3">
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
{environmentFullTitle({ type: "PRODUCTION" })}
</span>
</div>
<Input
{...conform.input(fields.productionBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
productionBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
{environmentFullTitle({ type: "STAGING" })}
</span>
</div>
<Input
{...conform.input(fields.stagingBranch, { type: "text" })}
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
placeholder="none"
variant="tertiary"
className="font-mono"
icon={GitBranchIcon}
onChange={(e) => {
setGitSettingsValues((prev) => ({
...prev,
stagingBranch: e.target.value,
}));
}}
/>
<div className="flex items-center gap-1.5">
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
{environmentFullTitle({ type: "PREVIEW" })}
</span>
</div>
<Switch
name="previewDeploymentsEnabled"
defaultChecked={connectedGitHubRepo.previewDeploymentsEnabled}
variant="small"
label="create preview deployments for pull requests"
labelPosition="right"
onCheckedChange={(checked) => {
setGitSettingsValues((prev) => ({
...prev,
previewDeploymentsEnabled: checked,
}));
}}
/>
</div>
<FormError>{fields.productionBranch?.error}</FormError>
<FormError>{fields.stagingBranch?.error}</FormError>
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
<FormError>{gitSettingsForm.error}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-git-settings"
variant="secondary/small"
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
const [buildSettingsValues, setBuildSettingsValues] = useState({
installDirectory: buildSettings?.installDirectory || "",
preBuildCommand: buildSettings?.preBuildCommand || "",
installCommand: buildSettings?.installCommand || "",
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
});
useEffect(() => {
const hasChanges =
buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") ||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "");
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
setHasBuildSettingsChanges(hasChanges);
}, [buildSettingsValues, buildSettings]);
@@ -1167,7 +546,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
<Input
{...conform.input(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, or `bun install`"
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
@@ -1175,27 +554,55 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
}));
}}
/>
<Hint>Command to install your project dependencies. Auto-detected by default.</Hint>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installDirectory.id}>Install directory</Label>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...conform.input(fields.installDirectory, { type: "text" })}
defaultValue={buildSettings?.installDirectory || ""}
placeholder=""
{...conform.input(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installDirectory: e.target.value,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>The directory where the install command is run in. Auto-detected by default.</Hint>
<FormError id={fields.installDirectory.errorId}>
{fields.installDirectory.error}
</FormError>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
id={fields.useNativeBuildServer.id}
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
Native build server builds do not rely on external build providers and will become the
default in the future. Version 4.2.0 or newer is required.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.error}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.error}</FormError>
<FormButtons
confirmButton={
@@ -73,6 +73,8 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
@@ -96,7 +98,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const presenter = new TestTaskPresenter();
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const result = await presenter.call({
userId,
@@ -126,6 +126,7 @@ export default function Page() {
runs={waitpoint.connectedRuns}
isLoading={false}
variant="bright"
disableAdjacentRows
/>
</div>
</div>
@@ -47,7 +47,7 @@ export const meta: MetaFunction = () => {
};
export async function loader({ params, request }: LoaderFunctionArgs) {
await requireUserId(request);
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { isManagedCloud } = featuresForRequest(request);
@@ -55,8 +55,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findUnique({
where: { slug: organizationSlug },
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
@@ -181,7 +181,7 @@ export default function Page() {
const fieldValues = useRef<string[]>(alerts.emails);
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
const checkboxLevels = [0.75, 0.9, 1.0];
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
useEffect(() => {
if (alerts.emails.length > 0) {

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