Major dashboard improvements (environment centric) (#1796)

* Delete the proxy app (was v2)

* Delete RunPresenterElectric

* Select the best proj/org/env

* Storing current proj/env in DB. Initial selection logic working with tasks page

* 2sm needed to be in the Tailwind merge list

* Move the task stream route (although we don’t actually use the env for now)

* Alerts moved from /v3

* API keys page moved from /v3

* Concurrency page moved from /v3

* WIP on side menu sections

* Improved the accordion animation

* Moved schedules from /v3

* More pages moved

* Move pages working

* Run page working

* Schedules working

* Moved deployments

* Alert pages moved

* Delete electric hooks, not used

* Started setting up blank states

* Test page working

* Removed “Select task” from the test page

* Some work on deployment page

* Style tweaks

* Redirect from project root to approriate env

* Improved env selector styling

* Fix for jsx errors

* Better min width on env selector

* Improved the env switching logic

* Added deployments to env routing

* Redirect deployments to the correct env

* Redirect run from proj to env

* JSX icon fix

* Only allow single env schedules from now on

* Remove env var count from the API keys page

* Move improvements and redirects

* Project settings moved

* Fix for scroll area on test page

* Tweaked the test design

* Made recent payloads column narrower

* Improved the test layout some more

* Added org icon, new project selector menu

* WIP on org switching menu

* Org switching is working

* New menu working well, removed old side menu items

* Buttons can now have a component name or an actual component for their icons

* Removed the Projects page, instead redirect appropriately

* Fix for broken blank states

* Minor run table improvements

* Removed unused switcher log and logic

* Concurrency page fix for invalid html, improved layout

* Minor improvements

* Moved the side menu to the project level

* Improved account styling

* Moved org settings pages (with redirects)

* Add current plan to billing side menu link

* Upgrade to get staging from env dropdown

* New env badge on concurrency limits page

* Show Run Engine version in span presenter

* New promote icon

* Concurrency limits page is the sum of engine v1 + v2 queues

* Fix for missing batch import

* Added currentConcurrencyOfEnvQueue function

* Basic avatar setting working

* Avatar setting is working

* You can change the color of your icon

* Avatar improvements

* Bugfix for mising prop

* Removed some old env badges

* Fixed replaying

* Removed EnvironmentLabel

* Old env badge deleted, changed everywhere to the new one

* Fix for Slack integration paths

* Fix for waitpoint completion form moving

* Bulk replay/cancel env fix

* Fix for alert webhook path

* Redirect projects/v3/* to project/*

* Fixes for CLI redirect routes

* Remove welcome email (unused)

* Change how we count schedules towards your limits

* Use new schedules limits when checking a schedule

* Added projectId back in to task queries (indexes)

* WIP dev presence

* CLI modal

* Moved things around and use Context

* Fix for p inside p

* Dev connected status on run page

* Correct dev env (not a teammates)

* Show disconnected message at the end

* Minor tweak on project dropdown icon padding

* Fix for inconsistent date format for presence

* Added a message when pushing to the billing page

* Center the team page

* Project settings page centered

* Improvements to the dev presence
This commit is contained in:
Matt Aitken
2025-03-17 13:32:31 +00:00
committed by GitHub
parent 2a51ae86a2
commit 7b862b9438
170 changed files with 5326 additions and 4215 deletions
-7
View File
@@ -1,7 +0,0 @@
REWRITE_HOSTNAME=
AWS_SQS_ACCESS_KEY_ID=
AWS_SQS_SECRET_ACCESS_KEY=
AWS_SQS_QUEUE_URL=
AWS_SQS_REGION=
#optional
#REWRITE_PORT=
-13
View File
@@ -1,13 +0,0 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
tab_width = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
-172
View File
@@ -1,172 +0,0 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
-11
View File
@@ -1,11 +0,0 @@
{
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
-72
View File
@@ -1,72 +0,0 @@
# proxy
## 0.0.11
### Patch Changes
- @trigger.dev/core@2.3.5
## 0.0.10
### Patch Changes
- @trigger.dev/core@2.3.4
## 0.0.9
### Patch Changes
- @trigger.dev/core@2.3.3
## 0.0.8
### Patch Changes
- @trigger.dev/core@2.3.2
## 0.0.7
### Patch Changes
- Updated dependencies [f3efcc0c]
- @trigger.dev/core@2.3.1
## 0.0.6
### Patch Changes
- Updated dependencies [17f6f29d]
- @trigger.dev/core@2.3.0
## 0.0.5
### Patch Changes
- @trigger.dev/core@2.2.11
## 0.0.4
### Patch Changes
- @trigger.dev/core@2.2.10
## 0.0.3
### Patch Changes
- Updated dependencies [6ebd435e]
- @trigger.dev/core@2.2.9
## 0.0.2
### Patch Changes
- Updated dependencies [067e19fe]
- @trigger.dev/core@2.2.8
## 0.0.1
### Patch Changes
- Updated dependencies [756024da]
- @trigger.dev/core@2.2.7
-68
View File
@@ -1,68 +0,0 @@
# Trigger.dev proxy
This is an optional module that can be used to proxy and queue requests to the Trigger.dev API.
## Why?
The Trigger.dev API is designed to be fast and reliable. However, if you have a lot of traffic, you may want to use this proxy to queue requests to the API. It intercepts some requests to the API and adds them to an AWS SQS queue, then the webapp can be setup to process the queue.
## Current features
- Intercepts `sendEvent` requests and adds them to an AWS SQS queue. The webapp then reads from the queue and creates the events.
## Setup
### Create an AWS SQS queue
In AWS you should create a new AWS SQS queue with appropriate security settings. You will need the queue URL for the next step.
### Environment variables
#### Cloudflare secrets
Locally you should copy the `.dev.var.example` file to `.dev.var` and fill in the values.
When deploying you should use `wrangler` (the Cloudflare CLI tool) to set secrets. Make sure you set the correct --env ("staging" or "prod")
```bash
wrangler secret put REWRITE_HOSTNAME --env staging
wrangler secret put AWS_SQS_ACCESS_KEY_ID --env staging
wrangler secret put AWS_SQS_SECRET_ACCESS_KEY --env staging
wrangler secret put AWS_SQS_QUEUE_URL --env staging
wrangler secret put AWS_SQS_REGION --env staging
```
You need to set your API CNAME entry to be proxied by Cloudflare. You can do this in the Cloudflare dashboard.
#### Webapp
These env vars also need setting in the webapp.
```bash
AWS_SQS_REGION
AWS_SQS_ACCESS_KEY_ID
AWS_SQS_SECRET_ACCESS_KEY
AWS_SQS_QUEUE_URL
AWS_SQS_BATCH_SIZE
```
## Deployment
Staging:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env staging
```
Prod:
```bash
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env prod
```
## Development
Set the environment variables as described above.
1. `pnpm install`
2. `pnpm run dev --filter proxy`
-21
View File
@@ -1,21 +0,0 @@
{
"name": "proxy",
"version": "0.0.11",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"wrangler": "^3.57.1"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.445.0",
"@trigger.dev/core": "workspace:*",
"ulidx": "^2.2.1",
"zod": "3.23.8",
"zod-error": "1.5.0"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { z } from "zod";
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export function getApiKeyFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
return;
}
const apiKey = authorization.data.replace(/^Bearer /, "");
const type = isPrivateApiKey(apiKey) ? ("PRIVATE" as const) : ("PUBLIC" as const);
return { apiKey, type };
}
function isPrivateApiKey(key: string) {
return key.startsWith("tr_");
}
-87
View File
@@ -1,87 +0,0 @@
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendEventBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvent(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendEventBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const timestamp = body.data.event.timestamp ?? new Date();
//add the event to the queue
const send = new SendMessageCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
MessageBody: JSON.stringify({
event: { ...body.data.event, timestamp },
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
});
const queuedEvent = await client.send(send);
console.log("Queued event", queuedEvent);
//respond with the event
const event: ApiEventLog = {
id: body.data.event.id,
name: body.data.event.name,
payload: body.data.event.payload,
context: body.data.event.context,
timestamp,
deliverAt: calculateDeliverAt(body.data.options),
};
return json(event, {
status: 200,
});
} catch (e) {
console.error("queueEvent error", e);
return json(
{
error: `Failed to send event: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
-112
View File
@@ -1,112 +0,0 @@
import { SQSClient, SendMessageBatchCommand } from "@aws-sdk/client-sqs";
import { ApiEventLog, SendBulkEventsBodySchema } from "@trigger.dev/core";
import { generateErrorMessage } from "zod-error";
import { Env } from "..";
import { getApiKeyFromRequest } from "../apikey";
import { json } from "../json";
import { calculateDeliverAt } from "./utils";
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
export async function queueEvents(request: Request, env: Env): Promise<Response> {
//check there's a private API key
const apiKeyResult = getApiKeyFromRequest(request);
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
return json(
{ error: "Invalid or Missing API key" },
{
status: 401,
}
);
}
//parse the request body
try {
const anyBody = await request.json();
const body = SendBulkEventsBodySchema.safeParse(anyBody);
if (!body.success) {
return json(
{ error: generateErrorMessage(body.error.issues) },
{
status: 422,
}
);
}
// The AWS SDK tries to use crypto from off of the window,
// so we need to trick it into finding it where it expects it
globalThis.global = globalThis;
const client = new SQSClient({
region: env.AWS_SQS_REGION,
credentials: {
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
},
});
const updatedEvents: ApiEventLog[] = body.data.events.map((event) => {
const timestamp = event.timestamp ?? new Date();
return {
...event,
payload: event.payload,
timestamp,
};
});
//divide updatedEvents into multiple batches of 10 (max size SQS accepts)
const batches: ApiEventLog[][] = [];
let currentBatch: ApiEventLog[] = [];
for (let i = 0; i < updatedEvents.length; i++) {
currentBatch.push(updatedEvents[i]);
if (currentBatch.length === 10) {
batches.push(currentBatch);
currentBatch = [];
}
}
if (currentBatch.length > 0) {
batches.push(currentBatch);
}
//loop through the batches and send them
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
//add the event to the queue
const send = new SendMessageBatchCommand({
// use wrangler secrets to provide this global variable
QueueUrl: env.AWS_SQS_QUEUE_URL,
Entries: batch.map((event, index) => ({
Id: `event-${index}`,
MessageBody: JSON.stringify({
event,
options: body.data.options,
apiKey: apiKeyResult.apiKey,
}),
})),
});
const queuedEvent = await client.send(send);
console.log("Queued events", queuedEvent);
}
//respond with the events
const events: ApiEventLog[] = updatedEvents.map((event) => ({
...event,
payload: event.payload,
deliverAt: calculateDeliverAt(body.data.options),
}));
return json(events, {
status: 200,
});
} catch (e) {
console.error("queueEvents error", e);
return json(
{
error: `Failed to send events: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
},
{
status: 422,
}
);
}
}
-15
View File
@@ -1,15 +0,0 @@
import { SendEventOptions } from "@trigger.dev/core";
export function calculateDeliverAt(options?: SendEventOptions) {
// If deliverAt is a string and a valid date, convert it to a Date object
if (options?.deliverAt) {
return options?.deliverAt;
}
// deliverAfter is the number of seconds to wait before delivering the event
if (options?.deliverAfter) {
return new Date(Date.now() + options.deliverAfter * 1000);
}
return undefined;
}
-53
View File
@@ -1,53 +0,0 @@
import { queueEvent } from "./events/queueEvent";
import { queueEvents } from "./events/queueEvents";
import { applyRateLimit } from "./rateLimit";
import { Ratelimit } from "./rateLimiter";
export interface Env {
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
REWRITE_HOSTNAME: string;
REWRITE_PORT?: string;
AWS_SQS_ACCESS_KEY_ID: string;
AWS_SQS_SECRET_ACCESS_KEY: string;
AWS_SQS_QUEUE_URL: string;
AWS_SQS_REGION: string;
//rate limiter
API_RATE_LIMITER: Ratelimit;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (!queueingIsEnabled(env)) {
console.log("Missing AWS credentials. Passing through to the origin.");
return fetch(request);
}
const url = new URL(request.url);
switch (url.pathname) {
case "/api/v1/events": {
if (request.method === "POST") {
return applyRateLimit(request, env, () => queueEvent(request, env));
}
break;
}
case "/api/v1/events/bulk": {
if (request.method === "POST") {
return applyRateLimit(request, env, () => queueEvents(request, env));
}
break;
}
}
//the same request but with the hostname (and port) changed
return fetch(request);
},
};
function queueingIsEnabled(env: Env) {
return (
env.AWS_SQS_ACCESS_KEY_ID &&
env.AWS_SQS_SECRET_ACCESS_KEY &&
env.AWS_SQS_QUEUE_URL &&
env.AWS_SQS_REGION
);
}
-13
View File
@@ -1,13 +0,0 @@
export function json(body: any, init?: ResponseInit) {
const headers = {
"content-type": "application/json",
...(init?.headers ?? {}),
};
const responseInit: ResponseInit = {
...(init ?? {}),
headers,
};
return new Response(JSON.stringify(body), responseInit);
}
-46
View File
@@ -1,46 +0,0 @@
import { Env } from "src";
import { getApiKeyFromRequest } from "./apikey";
import { json } from "./json";
export async function applyRateLimit(
request: Request,
env: Env,
fn: () => Promise<Response>
): Promise<Response> {
const apiKey = getApiKeyFromRequest(request);
if (apiKey) {
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
const { success } = result;
console.log(`Rate limiter`, {
success,
key: `${apiKey.apiKey.substring(0, 12)}...`,
});
if (!success) {
//60s in the future
const reset = Date.now() + 60 * 1000;
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return json(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
reset,
},
{
status: 429,
headers: {
"x-ratelimit-reset": reset.toString(),
},
}
);
}
} else {
console.log(`Rate limiter: no API key for request`);
}
//call the original function
return fn();
}
-23
View File
@@ -1,23 +0,0 @@
export interface Ratelimit {
/*
* The ratelimit function
* @param {RatelimitOptions} options
* @returns {Promise<RatelimitResponse>}
*/
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
}
export interface RatelimitOptions {
/*
* The key to identify the user, can be an IP address, user ID, etc.
*/
key: string;
}
export interface RatelimitResponse {
/*
* The ratelimit success status
* @returns {boolean}
*/
success: boolean;
}
-35
View File
@@ -1,35 +0,0 @@
{
"compilerOptions": {
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
"module": "es2022" /* Specify what module code is generated. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
"resolveJsonModule": true /* Enable importing .json files */,
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
"noEmit": true /* Disable emitting files from a compilation. */,
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
"baseUrl": ".",
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"]
}
}
}
-33
View File
@@ -1,33 +0,0 @@
name = "proxy"
main = "src/index.ts"
compatibility_date = "2024-05-13"
compatibility_flags = [ "nodejs_compat" ]
[env.staging]
# The rate limiting API is in open beta.
[[env.staging.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "1"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 100, period = 60 }
[env.prod]
# The rate limiting API is in open beta.
[[env.prod.unsafe.bindings]]
name = "API_RATE_LIMITER"
type = "ratelimit"
# An identifier you define, that is unique to your Cloudflare account.
# Must be an integer.
namespace_id = "2"
# Limit: the number of tokens allowed within a given period in a single
# Cloudflare location
# Period: the duration of the period, in seconds. Must be either 10 or 60
simple = { limit = 300, period = 60 }
@@ -0,0 +1,53 @@
export function ConnectedIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="0.5"
y="-0.5"
width="19"
height="1"
rx="0.5"
transform="matrix(1 0 0 -1 0 17)"
stroke="#878C99"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4187 2L4 2C2.89543 2 2 2.89543 2 4L2 12C2 13.1046 2.89543 14 4 14L16 14C17.1046 14 18 13.1046 18 12L18 4.9816L16.5 6.4816L16.5 12C16.5 12.2761 16.2761 12.5 16 12.5L4 12.5C3.72386 12.5 3.5 12.2761 3.5 12L3.5 4C3.5 3.72386 3.72386 3.5 4 3.5L10.9187 3.5L12.4187 2Z"
fill="#878C99"
/>
<path
d="M6.5 6.75L9.5 9.75L16 3"
stroke="#28BF5C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function DisconnectedIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.2279 14.8334L1.50037 14.8334C1.04013 14.8334 0.667035 15.2065 0.667035 15.6667C0.667035 16.1269 1.04013 16.5 1.50037 16.5L12.8945 16.5L11.2279 14.8334Z"
fill="#878C99"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.33268 5.93927L2.33268 11.1667C2.33268 12.2713 3.22811 13.1667 4.33268 13.1667L9.56016 13.1667L8.06016 11.6667L4.33268 11.6667C4.05654 11.6667 3.83268 11.4429 3.83268 11.1667L3.83268 7.43927L2.33268 5.93927ZM14.166 10.6369L14.166 5.16675C14.166 4.8906 13.9422 4.66675 13.666 4.66675L8.19589 4.66675L6.69589 3.16675L13.666 3.16675C14.7706 3.16675 15.666 4.06218 15.666 5.16675L15.666 11.1667C15.666 11.4522 15.6062 11.7237 15.4985 11.9693L14.166 10.6369Z"
fill="#878C99"
/>
<path
d="M1.5 1.50006L16.5 16.5001"
stroke="#E11D48"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,20 @@
export function DropdownIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.5 9L12 5L16.5 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.5 15L12 19L16.5 15"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,92 @@
export function DevEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_15347_71059)">
<circle cx="7" cy="7" r="2" fill="currentColor" />
<path
d="M4.5 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.25 4.5L13.25 2.75C13.25 1.64543 12.3546 0.75 11.25 0.75L9.5 0.75"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.5 13.25L11.25 13.25C12.3546 13.25 13.25 12.3546 13.25 11.25L13.25 9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M0.75 9.5L0.75 11.25C0.75 12.3546 1.64543 13.25 2.75 13.25L4.5 13.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_15347_71059">
<rect width="14" height="14" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}
export function ProdEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="0.75"
y="0.75"
width="12.5"
height="12.5"
rx="3.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g clipPath="url(#clip0_15515_83281)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.34731 4.15348C7.21887 3.84467 6.78141 3.84467 6.65298 4.15348L5.92066 5.91419L4.01982 6.06658C3.68644 6.0933 3.55126 6.50935 3.80526 6.72693L5.2535 7.96749L4.81104 9.82238C4.73344 10.1477 5.08735 10.4048 5.37277 10.2305L7.00014 9.23651L8.62752 10.2305C8.91294 10.4048 9.26685 10.1477 9.18925 9.82238L8.74679 7.96749L10.195 6.72693C10.449 6.50935 10.3138 6.0933 9.98046 6.06658L8.07963 5.91419L7.34731 4.15348Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_15515_83281">
<rect width="8" height="8" fill="currentColor" transform="translate(3 3)" />
</clipPath>
</defs>
</svg>
);
}
export function DeployedEnvironmentIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect
x="0.75"
y="0.75"
width="12.5"
height="12.5"
rx="3.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="7" cy="7" r="2" fill="currentColor" />
</svg>
);
}
@@ -0,0 +1,24 @@
export function PromoteIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.5 15.75H5.25493C4.4265 15.75 3.75493 16.4216 3.75493 17.25V19.741C3.75493 20.5695 4.4265 21.241 5.25493 21.241H18.7582C19.5866 21.241 20.2582 20.5695 20.2582 19.741V17.25C20.2582 16.4216 19.5866 15.75 18.7582 15.75H14.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M9.5 7.75H5.25493C4.4265 7.75 3.75493 8.42157 3.75493 9.25V11.741C3.75493 12.5695 4.4265 13.241 5.25493 13.241H9.5M14.5 7.75002H18.7582C19.5866 7.75002 20.2582 8.42159 20.2582 9.25002V11.741C20.2582 12.5695 19.5866 13.241 18.7582 13.241H14.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path d="M12 18L12 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path
d="M9 5.22892L12 2.75L15 5.22892"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,10 @@
export function ToggleArrowIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 6 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.43301 4.25C3.24056 4.58333 2.75944 4.58333 2.56699 4.25L0.834937 1.25C0.642486 0.916667 0.883049 0.5 1.26795 0.5L4.73205 0.5C5.11695 0.5 5.35751 0.916667 5.16506 1.25L3.43301 4.25Z"
fill="currentColor"
/>
</svg>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,421 @@
import {
BeakerIcon,
BellAlertIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
ClockIcon,
PlusIcon,
RectangleGroupIcon,
ServerStackIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
import {
docsPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3NewProjectAlertPath,
v3NewSchedulePath,
} from "~/utils/pathBuilder";
import { InlineCode } from "./code/InlineCode";
import { environmentFullTitle } from "./environments/EnvironmentLabel";
import { Feedback } from "./Feedback";
import { Button, LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { InfoPanel } from "./primitives/InfoPanel";
import { Paragraph } from "./primitives/Paragraph";
import { StepNumber } from "./primitives/StepNumber";
import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
import { StepContentContainer } from "./StepContentContainer";
import { useLocation } from "react-use";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { TextLink } from "./primitives/TextLink";
import { EnvironmentSelector } from "./navigation/EnvironmentSelector";
import { Pi } from "lucide-react";
export function HasNoTasksDev() {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<Header1 spacing>Get setup in 3 minutes</Header1>
<div className="flex items-center gap-2">
<Feedback
button={
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
<StepContentContainer>
<InitCommandV3 />
<Paragraph spacing>
You'll notice a new folder in your project called{" "}
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
in here to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
}
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
return (
<InfoPanel
title="You don't have any deployed tasks"
icon={TaskIcon}
iconClassName="text-blue-500"
>
<Paragraph spacing variant="small">
You don't have any deployed tasks in {environmentFullTitle(environment)}.
</Paragraph>
<LinkButton
to={docsPath("deployment/overview")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
>
How to deploy tasks
</LinkButton>
</InfoPanel>
);
}
export function SchedulesNoPossibleTaskPanel() {
return (
<InfoPanel
title="Create your first scheduled task"
icon={ClockIcon}
iconClassName="text-sun-500"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You have no scheduled tasks in your project. Before you can schedule a task you need to
create a <InlineCode>schedules.task</InlineCode>.
</Paragraph>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
>
View the docs
</LinkButton>
</InfoPanel>
);
}
export function SchedulesNoneAttached() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
return (
<InfoPanel
title="Attach your first schedule"
icon={ClockIcon}
iconClassName="text-sun-500"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
this in the dashboard or using the SDK.
</Paragraph>
<div className="flex gap-2">
<LinkButton
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
variant="primary/small"
LeadingIcon={RectangleGroupIcon}
className="inline-flex"
>
Use the dashboard
</LinkButton>
<LinkButton
to={docsPath("v3/tasks-scheduled")}
variant="primary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Use the SDK
</LinkButton>
</div>
</InfoPanel>
);
}
export function BatchesNone() {
return (
<InfoPanel
title="Triggering batches"
icon={Squares2X2Icon}
iconClassName="text-blue-500"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You have no batches in this environment. You can trigger batches from your backend or from
inside other tasks.
</Paragraph>
<LinkButton to={docsPath("triggering")} variant="docs/medium" LeadingIcon={BookOpenIcon}>
How to trigger batches
</LinkButton>
</InfoPanel>
);
}
export function TestHasNoTasks() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
title="No tasks to test"
icon={BeakerIcon}
iconClassName="text-lime-500"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You have no tasks in this environment.
</Paragraph>
<LinkButton
to={v3EnvironmentPath(organization, project, environment)}
variant="tertiary/medium"
>
Add tasks
</LinkButton>
</InfoPanel>
);
}
export function DeploymentsNone() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-blue-500"
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, Continuous Integration
(like GitHub Actions), or an integration with a service like Netlify or Vercel. 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>
);
}
export function DeploymentsNoneDev() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-blue-500"
title="Deploying tasks"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
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, Continuous Integration
(like GitHub Actions), or an integration with a service like Netlify or Vercel. 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>
);
}
export function AlertsNoneDev() {
return (
<div className="space-y-8">
<InfoPanel
icon={BellAlertIcon}
iconClassName="text-red-500"
title="Adding alerts"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You can get alerted when deployed runs fail.
</Paragraph>
<Paragraph spacing variant="small">
We don't support alerts in the Development environment. Switch to a deployed environment
to setup alerts.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("troubleshooting-alerts")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
How to setup alerts
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
);
}
export function AlertsNoneDeployed() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div className="space-y-8">
<InfoPanel
icon={BellAlertIcon}
iconClassName="text-red-500"
title="Adding alerts"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You can get alerted when deployed runs fail. We currently support sending Slack, Email,
and webhooks.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={v3NewProjectAlertPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
<LinkButton
to={docsPath("troubleshooting-alerts")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Alert docs
</LinkButton>
</div>
</InfoPanel>
</div>
);
}
function AlertsNoneProd() {
return (
<div className="space-y-8">
<InfoPanel
icon={BellAlertIcon}
iconClassName="text-red-500"
title="Adding alerts"
panelClassName="max-w-full"
>
<Paragraph spacing variant="small">
You can get alerted when deployed runs fail.
</Paragraph>
<Paragraph spacing variant="small">
We don't support alerts in the Development environment. Switch to a deployed environment
to setup alerts.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("troubleshooting-alerts")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
How to setup alerts
</LinkButton>
</div>
</InfoPanel>
<SwitcherPanel />
</div>
);
}
function SwitcherPanel() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div className="flex items-center rounded-md border border-grid-bright bg-background-bright p-3">
<Paragraph variant="small" className="grow">
Switch to a deployed environment
</Paragraph>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-auto grow-0 rounded-sm bg-grid-bright"
/>
</div>
);
}
@@ -0,0 +1,88 @@
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
import { useDebounce } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
// Define Context types
type DevPresenceContextType = {
lastSeen: Date | null;
isConnected: boolean;
};
// Create Context with default values
const DevPresenceContext = createContext<DevPresenceContextType>({
lastSeen: null,
isConnected: false,
});
// Provider component with enabled prop
interface DevPresenceProviderProps {
children: ReactNode;
enabled?: boolean;
}
export function DevPresenceProvider({ children, enabled = true }: DevPresenceProviderProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Only subscribe to event source if enabled is true
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dev/presence`,
{
event: "presence",
disabled: !enabled,
}
);
const [lastSeen, setLastSeen] = useState<Date | null>(null);
const debouncer = useDebounce((seen: Date | null) => {
setLastSeen(seen);
}, 3_000);
useEffect(() => {
// If disabled or no events, set lastSeen to null
if (!enabled || streamedEvents === null) {
debouncer(null);
return;
}
try {
const data = JSON.parse(streamedEvents) as any;
if ("lastSeen" in data && data.lastSeen) {
try {
const lastSeenDate = new Date(data.lastSeen);
debouncer(lastSeenDate);
} catch (error) {
console.log("DevPresence: Failed to parse lastSeen timestamp", { error });
debouncer(null);
}
} else {
debouncer(null);
}
} catch (error) {
console.log("DevPresence: Failed to parse presence message", { error });
debouncer(null);
}
}, [streamedEvents, enabled]);
// Calculate isConnected and memoize the context value
const contextValue = useMemo(() => {
const isConnected = enabled && lastSeen !== null && lastSeen > new Date(Date.now() - 120_000);
return { lastSeen, isConnected };
}, [lastSeen, enabled]);
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
}
// Custom hook to use the context
export function useDevPresence() {
const context = useContext(DevPresenceContext);
if (context === undefined) {
throw new Error("useDevPresence must be used within a DevPresenceProvider");
}
return context;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { LinkButton } from "./primitives/Buttons";
import { Header1 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import Spline from "@splinetool/react-spline";
import { ReactNode } from "react";
import { type ReactNode } from "react";
type ErrorDisplayOptions = {
button?: {
+1 -1
View File
@@ -63,7 +63,7 @@ function getApiUrlArg() {
export function InitCommandV3() {
const project = useProject();
const projectRef = project.ref;
const projectRef = project.externalRef;
const apiUrlArg = getApiUrlArg();
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
@@ -58,7 +58,7 @@ function Content({ children }: { children: React.ReactNode }) {
</Property.Item>
<Property.Item>
<Property.Label>Project ref</Property.Label>
<Property.Value>{project.ref}</Property.Value>
<Property.Value>{project.externalRef}</Property.Value>
</Property.Item>
</>
)}
@@ -25,7 +25,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
<span className="text-2sm text-text-bright">Free Plan</span>
</div>
<Link to={to} className="text-2sm text-text-link focus-custom">
Upgrade
@@ -2,131 +2,67 @@ import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
import { sortEnvironments } from "~/utils/environmentSort";
import { SimpleTooltip } from "../primitives/Tooltip";
import {
DeployedEnvironmentIcon,
DevEnvironmentIcon,
ProdEnvironmentIcon,
} from "~/assets/icons/EnvironmentIcons";
type Environment = Pick<RuntimeEnvironment, "type">;
const variants = {
small: "h-4 text-xxs px-[0.1875rem] rounded-[2px]",
large: "h-6 text-xs px-1.5 rounded",
};
export function EnvironmentTypeLabel({
export function EnvironmentIcon({
environment,
className,
}: {
environment: Environment;
className?: string;
}) {
switch (environment.type) {
case "DEVELOPMENT":
return (
<DevEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
);
case "PRODUCTION":
return (
<ProdEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
);
case "STAGING":
case "PREVIEW":
return (
<DeployedEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
);
}
}
export function EnvironmentCombo({
environment,
size = "small",
className,
}: {
environment: Environment;
size?: keyof typeof variants;
className?: string;
}) {
return (
<span
className={cn(
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName(environment),
environmentTextClassName(environment),
variants[size],
className
)}
>
{environmentTypeTitle(environment)}
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
<EnvironmentIcon environment={environment} className="size-4" />
<EnvironmentLabel environment={environment} />
</span>
);
}
export function EnvironmentLabel({
environment,
size = "small",
userName,
className,
}: {
environment: Environment;
size?: keyof typeof variants;
userName?: string;
className?: string;
}) {
return (
<span
className={cn(
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName(environment),
environmentTextClassName(environment),
variants[size],
className
)}
>
{environmentTitle(environment, userName)}
<span className={cn(environmentTextClassName(environment), className)}>
{environmentFullTitle(environment)}
</span>
);
}
type EnvironmentWithUsername = Environment & { userName?: string };
export function EnvironmentLabels({
environments,
size = "small",
className,
}: {
environments: EnvironmentWithUsername[];
size?: keyof typeof variants;
className?: string;
}) {
const devEnvironments = sortEnvironments(
environments.filter((env) => env.type === "DEVELOPMENT")
);
const firstDevEnvironment = devEnvironments[0];
const otherDevEnvironments = devEnvironments.slice(1);
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
return (
<div className={cn("flex items-baseline gap-2", className)}>
{firstDevEnvironment && (
<EnvironmentLabel
environment={firstDevEnvironment}
userName={firstDevEnvironment.userName}
size={size}
/>
)}
{otherDevEnvironments.length > 0 ? (
<SimpleTooltip
disableHoverableContent
button={
<span
className={cn(
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName({ type: "DEVELOPMENT" }),
environmentTextClassName({ type: "DEVELOPMENT" }),
variants[size]
)}
>
+{otherDevEnvironments.length}
</span>
}
content={
<div className="flex gap-1 py-1">
{otherDevEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
}
/>
) : null}
{otherEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
);
}
export function environmentTitle(environment: Environment, username?: string) {
switch (environment.type) {
case "PRODUCTION":
@@ -140,6 +76,19 @@ export function environmentTitle(environment: Environment, username?: string) {
}
}
export function environmentFullTitle(environment: Environment) {
switch (environment.type) {
case "PRODUCTION":
return "Production";
case "STAGING":
return "Staging";
case "DEVELOPMENT":
return "Development";
case "PREVIEW":
return "Preview";
}
}
export function environmentTypeTitle(environment: Environment) {
switch (environment.type) {
case "PRODUCTION":
@@ -66,3 +66,24 @@ export function MainCenteredContainer({
</div>
);
}
export function MainHorizontallyCenteredContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className="w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div
className={cn(
"mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 md:mt-14",
className
)}
>
{children}
</div>
</div>
);
}
@@ -1,7 +1,6 @@
import { ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { User } from "@trigger.dev/database";
import { useFeatures } from "~/hooks/useFeatures";
import { type User } from "@trigger.dev/database";
import { cn } from "~/utils/cn";
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
@@ -10,54 +9,43 @@ import { SideMenuItem } from "./SideMenuItem";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
export function AccountSideMenu({ user }: { user: User }) {
const { v3Enabled } = useFeatures();
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
)}
>
<div className="flex h-full flex-col">
<div className={cn("flex h-10 items-center justify-between border-b p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
>
Account
</LinkButton>
</div>
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="mb-6 flex flex-col gap-1 px-1">
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
<SideMenuItem
name="Your profile"
icon={UserCircleIcon}
activeIconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
</div>
{v3Enabled && (
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title="Security" />
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
activeIconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
</div>
)}
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
</div>
<div className={cn("flex items-center justify-between p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
className="text-text-bright"
>
Back to app
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col gap-1 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<SideMenuHeader title="Account" />
<SideMenuItem
name="Profile"
icon={UserCircleIcon}
activeIconColor="text-indigo-500"
to={accountPath()}
data-action="account"
/>
<SideMenuItem
name="Personal Access Tokens"
icon={ShieldCheckIcon}
activeIconColor="text-emerald-500"
to={personalAccessTokensPath()}
data-action="tokens"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
</div>
</div>
);
@@ -0,0 +1,90 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverSectionHeader,
} from "../primitives/Popover";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { cn } from "~/utils/cn";
import { useFeatures } from "~/hooks/useFeatures";
import { v3BillingPath } from "~/utils/pathBuilder";
import { TextLink } from "../primitives/TextLink";
export function EnvironmentSelector({
organization,
project,
environment,
className,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
className?: string;
}) {
const { isManagedCloud } = useFeatures();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigation = useNavigation();
const { urlForEnvironment } = useEnvironmentSwitcher();
useEffect(() => {
setIsMenuOpen(false);
}, [navigation.location?.pathname]);
const hasStaging = project.environments.some((env) => env.type === "STAGING");
return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
<PopoverArrowTrigger
isOpen={isMenuOpen}
overflowHidden
fullWidth
className={cn("h-7 overflow-hidden py-1 pl-2", className)}
>
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
<div className="flex flex-col gap-1 p-1">
{project.environments.map((env) => (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
isSelected={env.id === environment.id}
/>
))}
</div>
{!hasStaging && isManagedCloud && (
<>
<PopoverSectionHeader title="Additional environments" />
<div className="p-1">
<PopoverMenuItem
key="staging"
to={v3BillingPath(
organization,
"Upgrade to unlock a Staging environment for your projects."
)}
title={
<div className="flex w-full items-center justify-between">
<EnvironmentCombo environment={{ type: "STAGING" }} className="text-2sm" />
<span className="text-indigo-500">Upgrade</span>
</div>
}
isSelected={false}
/>
</div>
</>
)}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,93 @@
import {
ChartBarIcon,
Cog8ToothIcon,
CreditCardIcon,
UserGroupIcon,
} from "@heroicons/react/20/solid";
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
import {
organizationSettingsPath,
organizationTeamPath,
rootPath,
v3BillingPath,
v3UsagePath,
} from "~/utils/pathBuilder";
import { LinkButton } from "../primitives/Buttons";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
export function OrganizationSettingsSideMenu({
organization,
}: {
organization: MatchedOrganization;
}) {
const { isManagedCloud } = useFeatures();
const currentPlan = useCurrentPlan();
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
)}
>
<div className={cn("flex items-center justify-between p-1 transition")}>
<LinkButton
variant="minimal/medium"
LeadingIcon={ArrowLeftIcon}
to={rootPath()}
fullWidth
textAlignLeft
className="text-text-bright"
>
Back to app
</LinkButton>
</div>
<div className="mb-6 flex grow flex-col gap-1 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<SideMenuHeader title="Organization" />
<SideMenuItem
name="Usage"
icon={ChartBarIcon}
activeIconColor="text-indigo-500"
to={v3UsagePath(organization)}
data-action="usage"
/>
{isManagedCloud && (
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
activeIconColor="text-emerald-500"
to={v3BillingPath(organization)}
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying
? currentPlan?.v3Subscription?.plan?.title
: undefined
}
/>
)}
<SideMenuItem
name="Team"
icon={UserGroupIcon}
activeIconColor="text-amber-500"
to={organizationTeamPath(organization)}
data-action="team"
/>
<SideMenuItem
name="Settings"
icon={Cog8ToothIcon}
activeIconColor="text-blue-500"
to={organizationSettingsPath(organization)}
data-action="settings"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
</div>
</div>
);
}
+428 -254
View File
@@ -1,48 +1,52 @@
import {
AcademicCapIcon,
ArrowPathRoundedSquareIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
BellAlertIcon,
BookOpenIcon,
ChartBarIcon,
ChevronRightIcon,
ClockIcon,
Cog8ToothIcon,
CreditCardIcon,
CogIcon,
FolderIcon,
FolderOpenIcon,
IdentificationIcon,
KeyIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
ShieldCheckIcon,
Squares2X2Icon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
import { useNavigation } from "@remix-run/react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { ConnectedIcon, DisconnectedIcon } from "~/assets/icons/ConnectionIcons";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { useFeatures } from "~/hooks/useFeatures";
import { Avatar } from "~/components/primitives/Avatar";
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { type User } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { FeedbackType } from "~/routes/resources.feedback";
import { type FeedbackType } from "~/routes/resources.feedback";
import { cn } from "~/utils/cn";
import {
accountPath,
inviteTeamMemberPath,
docsPath,
logoutPath,
newOrganizationPath,
newProjectPath,
organizationPath,
organizationSettingsPath,
organizationTeamPath,
personalAccessTokensPath,
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3ConcurrencyPath,
v3DeploymentsPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
v3ProjectAlertsPath,
v3ProjectPath,
@@ -52,42 +56,71 @@ import {
v3TestPath,
v3UsagePath,
} from "~/utils/pathBuilder";
import { useDevPresence } from "../DevPresence";
import { ImpersonationBanner } from "../ImpersonationBanner";
import { LogoIcon } from "../LogoIcon";
import { PackageManagerProvider, TriggerDevStepV3 } from "../SetupCommands";
import { UserProfilePhoto } from "../UserProfilePhoto";
import connectedImage from "../../assets/images/cli-connected.png";
import disconnectedImage from "../../assets/images/cli-disconnected.png";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverCustomTrigger,
PopoverMenuItem,
PopoverSectionHeader,
PopoverTrigger,
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../primitives/Tooltip";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments"
>;
export type SideMenuEnvironment = MatchedEnvironment;
type SideMenuProps = {
user: SideMenuUser;
project: SideMenuProject;
environment: SideMenuEnvironment;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
button?: ReactNode;
defaultValue?: FeedbackType;
};
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
export function SideMenu({
user,
project,
environment,
organization,
organizations,
}: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const currentPlan = useCurrentPlan();
const { isManagedCloud } = useFeatures();
const isV3Project = project.version === "V3";
const isFreeV3User = currentPlan?.v3Subscription?.isPaying === false;
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
useEffect(() => {
const handleScroll = () => {
@@ -106,105 +139,161 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
return (
<div
className={cn(
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
)}
>
<div className="flex h-full flex-col">
<div
className={cn(
"flex items-center justify-between px-1 py-1 transition",
showHeaderDivider ? " border-grid-bright" : "border-transparent"
)}
>
<ProjectSelector organizations={organizations} project={project} />
<UserMenu user={user} />
</div>
<div
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-1 px-1">
<V3ProjectSideMenu organization={organization} project={project} />
<div
className={cn(
"flex items-center justify-between px-1 py-1 transition",
showHeaderDivider ? " border-grid-bright" : "border-transparent"
)}
>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
/>
</div>
<div
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-4 px-1">
<div className="space-y-1">
<SideMenuHeader title={"Environment"} />
<div className="flex items-center gap-1">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
/>
{environment.type === "DEVELOPMENT" && <DevConnection />}
</div>
</div>
<div className="mb-1 flex flex-col gap-1 px-1">
<SideMenuHeader title={"Organization"}>
<PopoverMenuItem
to={newProjectPath(organization)}
title="New Project"
icon={PlusIcon}
/>
<PopoverMenuItem
to={inviteTeamMemberPath(organization)}
title="Invite team member"
icon={UserPlusIcon}
leadingIconClassName="text-indigo-500"
/>
</SideMenuHeader>
<div>
<SideMenuItem
name="Projects"
icon={FolderIcon}
to={organizationPath(organization)}
data-action="projects"
name="Tasks"
icon={TaskIcon}
activeIconColor="text-blue-500"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
/>
<SideMenuItem
name="Team"
icon={UserGroupIcon}
to={organizationTeamPath(organization)}
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-blue-500"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-sun-500"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-blue-500"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-lime-500"
to={v3TestPath(organization, project, environment)}
data-action="test"
/>
</div>
<SideMenuSection title="Observability">
<SideMenuItem
name="Runs"
icon={RunsIcon}
activeIconColor="text-teal-500"
to={v3RunsPath(organization, project, environment)}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
/>
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-amber-500"
data-action="team"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
/>
<SideMenuItem
name="Usage"
icon={ChartBarIcon}
to={v3UsagePath(organization)}
activeIconColor="text-green-600"
data-action="usage"
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-pink-500"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
/>
<SideMenuItem
name="Concurrency limits"
icon={RectangleStackIcon}
activeIconColor="text-indigo-500"
to={v3ConcurrencyPath(organization, project, environment)}
data-action="concurrency"
/>
<SideMenuItem
name="Billing"
icon={CreditCardIcon}
to={v3BillingPath(organization)}
activeIconColor="text-blue-600"
data-action="billing"
badge={
currentPlan?.v3Subscription?.isPaying
? currentPlan?.v3Subscription?.plan?.title
: undefined
}
/>
<SideMenuItem
name="Organization settings"
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-teal-500"
to={organizationSettingsPath(organization)}
data-action="organization-settings"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
/>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
{isFreeV3User && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
)}
</SideMenuSection>
</div>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
)}
</div>
</div>
);
}
function ProjectSelector({
project,
organization,
organizations,
user,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
user: SideMenuUser;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
const navigation = useNavigation();
let plan: string | undefined = undefined;
if (currentPlan?.v3Subscription?.isPaying === false) {
plan = "Free plan";
} else if (currentPlan?.v3Subscription?.isPaying === true) {
plan = currentPlan.v3Subscription.plan?.title;
}
useEffect(() => {
setOrgMenuOpen(false);
}, [navigation.location?.pathname]);
@@ -214,204 +303,289 @@ function ProjectSelector({
<PopoverArrowTrigger
isOpen={isOrgMenuOpen}
overflowHidden
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
className="h-8 w-full justify-between overflow-hidden py-1 pl-1.5"
>
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
<span className="truncate">{project.name ?? "Select a project"}</span>
<span className="flex items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} className="size-5" />
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</PopoverArrowTrigger>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
{organizations.map((organization) => (
<Fragment key={organization.id}>
<PopoverSectionHeader title={organization.title} />
<div className="flex flex-col gap-1 p-1">
{organization.projects.length > 0 ? (
organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={v3ProjectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-text-bright">
<span className="grow truncate text-left">{p.name}</span>
</div>
}
isSelected={isSelected}
icon={FolderIcon}
/>
);
})
) : (
<PopoverMenuItem
to={newProjectPath(organization)}
title="New project"
icon={PlusIcon}
/>
)}
<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 border border-charcoal-700 bg-charcoal-850">
<Avatar avatar={organization.avatar} className="size-10" includePadding />
</div>
</Fragment>
))}
<div className="space-y-0.5">
<Paragraph variant="extra-small/bright">{organization.title}</Paragraph>
<div className="flex items-baseline">
{plan && (
<Paragraph variant="extra-small">
<TextLink variant="secondary" to={v3BillingPath(organization)}>
{plan}
</TextLink>
</Paragraph>
)}
<Paragraph variant="extra-small">
<TextLink
variant="secondary"
to={organizationTeamPath(organization)}
>{simplur`${organization.membersCount} member[|s]`}</TextLink>
</Paragraph>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<LinkButton
variant="secondary/small"
to={organizationSettingsPath(organization)}
fullWidth
iconSpacing="gap-1.5"
>
<CogIcon className="size-4 text-text-dimmed" />
<span className="text-text-bright">Settings</span>
</LinkButton>
<LinkButton
variant="secondary/small"
to={v3UsagePath(organization)}
fullWidth
iconSpacing="gap-1.5"
>
<ChartBarIcon className="size-4 text-text-dimmed" />
<span className="text-text-bright">Usage</span>
</LinkButton>
</div>
</div>
<div className="flex flex-col gap-1 p-1">
{organization.projects.map((p) => {
const isSelected = p.id === project.id;
return (
<PopoverMenuItem
key={p.id}
to={v3ProjectPath(organization, p)}
title={
<div className="flex w-full items-center justify-between text-text-bright">
<span className="grow truncate text-left">{p.name}</span>
</div>
}
isSelected={isSelected}
icon={isSelected ? FolderOpenIcon : FolderIcon}
leadingIconClassName="text-indigo-500"
/>
);
})}
<PopoverMenuItem to={newProjectPath(organization)} title="New project" icon={PlusIcon} />
</div>
<div className="border-t border-charcoal-700 p-1">
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon={PlusIcon} />
<SwitchOrganizations organizations={organizations} organization={organization} />
</div>
<div className="border-t border-charcoal-700 p-1">
<PopoverMenuItem
to={accountPath()}
title="Account"
icon={UserProfilePhoto}
leadingIconClassName={cn(
"text-text-dimmed rounded-full border border-transparent",
user.isImpersonating && "rounded-full border-yellow-500"
)}
/>
{user.isImpersonating && <ImpersonationBanner />}
</div>
<div className="border-t border-charcoal-700 p-1">
<PopoverMenuItem
to={logoutPath()}
title="Logout"
icon={ArrowRightOnRectangleIcon}
leadingIconClassName="text-text-dimmed"
/>
</div>
</PopoverContent>
</Popover>
);
}
function UserMenu({ user }: { user: SideMenuUser }) {
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
function SwitchOrganizations({
organizations,
organization,
}: {
organizations: MatchedOrganization[];
organization: MatchedOrganization;
}) {
const navigation = useNavigation();
const { v3Enabled } = useFeatures();
const [isMenuOpen, setMenuOpen] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
useEffect(() => {
setProfileMenuOpen(false);
setMenuOpen(false);
}, [navigation.location?.pathname]);
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setMenuOpen(true);
};
const handleMouseLeave = () => {
// Small delay before closing to allow moving to the content
timeoutRef.current = setTimeout(() => {
setMenuOpen(false);
}, 150);
};
return (
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1 hover:bg-transparent">
<UserProfilePhoto
className={cn(
"h-5 w-5 rounded-full border border-transparent text-charcoal-600 transition hover:border-charcoal-600",
user.isImpersonating && "rounded-full border border-yellow-500"
)}
/>
</PopoverCustomTrigger>
<PopoverContent
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
>
<Fragment>
<PopoverSectionHeader title={user.email} variant="extra-small" />
<div className="flex flex-col gap-1 p-1">
{user.isImpersonating && <ImpersonationBanner />}
{user.admin && (
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<PopoverTrigger className="h-7 w-full justify-between overflow-hidden focus-custom">
<ButtonContent
variant="small-menu-item"
className="hover:bg-charcoal-750"
LeadingIcon={ArrowPathRoundedSquareIcon}
leadingIconClassName="text-text-dimmed"
TrailingIcon={ChevronRightIcon}
trailingIconClassName="text-text-dimmed"
textAlignLeft
fullWidth
>
Switch organization
</ButtonContent>
</PopoverTrigger>
<PopoverContent
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
side="right"
alignOffset={0}
sideOffset={-4}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="p-1">
{organizations.map((org) => (
<PopoverMenuItem
to={"/admin"}
title="Admin"
icon={AcademicCapIcon}
leadingIconClassName="text-yellow-500"
key={org.id}
to={organizationPath(org)}
title={org.title}
icon={<Avatar className="size-4" avatar={org.avatar} />}
leadingIconClassName="text-text-dimmed"
isSelected={org.id === organization.id}
/>
)}
))}
</div>
<div className="border-t border-charcoal-700 p-1">
<PopoverMenuItem
to={accountPath()}
title="View profile"
icon={UserProfilePhoto}
leadingIconClassName="text-indigo-500"
/>
{v3Enabled && (
<PopoverMenuItem
to={personalAccessTokensPath()}
title="Personal Access Tokens"
icon={ShieldCheckIcon}
leadingIconClassName="text-emerald-500"
/>
)}
<PopoverMenuItem
to={logoutPath()}
title="Log out"
icon={ArrowRightOnRectangleIcon}
leadingIconClassName="text-rose-500"
to={newOrganizationPath()}
title="New Organization"
icon={PlusIcon}
leadingIconClassName="text-text-dimmed"
/>
</div>
</Fragment>
</PopoverContent>
</PopoverContent>
</div>
</Popover>
);
}
function V3ProjectSideMenu({
project,
organization,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
}) {
function SelectorDivider() {
return (
<>
<SideMenuHeader title={"Project"} />
<SideMenuItem
name="Tasks"
icon={TaskIcon}
activeIconColor="text-blue-500"
to={v3ProjectPath(organization, project)}
data-action="tasks"
<svg width="6" height="21" viewBox="0 0 6 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<line
x1="5.3638"
y1="0.606339"
x2="0.606339"
y2="19.6362"
stroke="#3B3E45"
strokeLinecap="round"
/>
<SideMenuItem
name="Runs"
icon={RunsIcon}
activeIconColor="text-teal-500"
to={v3RunsPath(organization, project)}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-blue-500"
to={v3BatchesPath(organization, project)}
data-action="batches"
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-lime-500"
to={v3TestPath(organization, project)}
data-action="test"
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-sun-500"
to={v3SchedulesPath(organization, project)}
data-action="schedules"
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-amber-500"
to={v3ApiKeysPath(organization, project)}
data-action="api keys"
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-pink-500"
to={v3EnvironmentVariablesPath(organization, project)}
data-action="environment variables"
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-blue-500"
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
<SideMenuItem
name="Concurrency limits"
icon={RectangleStackIcon}
activeIconColor="text-indigo-500"
to={v3ConcurrencyPath(organization, project)}
data-action="concurrency"
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-teal-500"
to={v3ProjectSettingsPath(organization, project)}
data-action="project-settings"
/>
</>
</svg>
);
}
export function DevConnection() {
const { isConnected } = useDevPresence();
return (
<Dialog>
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="px-1"
LeadingIcon={
isConnected ? (
<ConnectedIcon className="size-5" />
) : (
<DisconnectedIcon className="size-5" />
)
}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected ? "Your dev server is connected" : "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DialogContent>
<DialogHeader>
{isConnected
? "Your dev server is connected to Trigger.dev"
: "Your dev server is not connected to Trigger.dev"}
</DialogHeader>
<div className="mt-2 flex flex-col gap-3 px-2">
<div className="flex flex-col items-center justify-center gap-6 px-6 py-10">
<img
src={isConnected ? connectedImage : disconnectedImage}
alt={isConnected ? "Connected" : "Disconnected"}
width={282}
height={45}
/>
<Paragraph variant="small" className={isConnected ? "text-success" : "text-error"}>
{isConnected
? "Your local dev server is connected to Trigger.dev"
: "Your local dev server is not connected to Trigger.dev"}
</Paragraph>
</div>
{isConnected ? null : (
<div className="space-y-3">
<PackageManagerProvider>
<TriggerDevStepV3 />
</PackageManagerProvider>
<Paragraph variant="small">
Run this CLI `dev` command to connect to the Trigger.dev servers to start developing
locally. Keep it running while you develop to stay connected.
</Paragraph>
</div>
)}
</div>
<DialogFooter>
<LinkButton variant="tertiary/medium" LeadingIcon={BookOpenIcon} to={docsPath("cli-dev")}>
CLI docs
</LinkButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,5 @@
import { useNavigation } from "@remix-run/react";
import { useEffect, useState } from "react";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
@@ -14,12 +13,7 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
return (
<div className="group flex items-center justify-between pl-1.5">
<Paragraph
variant="extra-extra-small/caps"
className="cursor-default truncate text-charcoal-500"
>
{title}
</Paragraph>
<h2 className="text-xs">{title}</h2>
{children !== undefined ? (
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
<PopoverCustomTrigger className="p-1">
@@ -2,6 +2,7 @@ import { type AnchorHTMLAttributes } from "react";
import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { LinkButton } from "../primitives/Buttons";
import { type RenderIcon } from "../primitives/Icon";
export function SideMenuItem({
icon,
@@ -13,25 +14,23 @@ export function SideMenuItem({
to,
badge,
target,
subItem = false,
}: {
icon?: React.ComponentType<any>;
icon?: RenderIcon;
activeIconColor?: string;
inactiveIconColor?: string;
trailingIcon?: React.ComponentType<any>;
trailingIcon?: RenderIcon;
trailingIconClassName?: string;
name: string;
to: string;
badge?: string;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
subItem?: boolean;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<LinkButton
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={icon}
@@ -42,7 +41,6 @@ export function SideMenuItem({
target={target}
className={cn(
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
subItem ? "text-text-dimmed" : "",
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
)}
>
@@ -0,0 +1,85 @@
import { AnimatePresence, motion } from "framer-motion";
import React, { useCallback, useState } from "react";
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
type Props = {
title: string;
initialCollapsed?: boolean;
onCollapseToggle?: (isCollapsed: boolean) => void;
children: React.ReactNode;
};
/** A collapsible section for the side menu
* The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
*/
export function SideMenuSection({
title,
initialCollapsed = false,
onCollapseToggle,
children,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const handleToggle = useCallback(() => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
onCollapseToggle?.(newIsCollapsed);
}, [isCollapsed, onCollapseToggle]);
return (
<div>
<div
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1 text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
onClick={handleToggle}
>
<h2 className="text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</div>
<AnimatePresence initial={false}>
<motion.div
initial={isCollapsed ? "collapsed" : "expanded"}
animate={isCollapsed ? "collapsed" : "expanded"}
exit="collapsed"
variants={{
expanded: {
height: "auto",
transition: {
height: { duration: 0.3, ease: "easeOut" },
},
},
collapsed: {
height: 0,
transition: {
height: { duration: 0.2, ease: "easeIn" },
},
},
}}
style={{ overflow: "hidden" }}
>
<motion.div
variants={{
expanded: {
translateY: 0,
opacity: 1,
transition: { duration: 0.3, ease: "easeOut" },
},
collapsed: {
translateY: "-100%",
opacity: 0,
transition: { duration: 0.2, ease: "easeIn" },
},
}}
>
{children}
</motion.div>
</motion.div>
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,199 @@
import {
BuildingOffice2Icon,
CodeBracketSquareIcon,
CubeIcon,
FaceSmileIcon,
FireIcon,
RocketLaunchIcon,
StarIcon,
} from "@heroicons/react/24/solid";
import { type Prisma } from "@trigger.dev/database";
import { useLayoutEffect, useRef, useState } from "react";
import { z } from "zod";
import { useOrganization } from "~/hooks/useOrganizations";
import { logger } from "~/services/logger.server";
import { cn } from "~/utils/cn";
export const AvatarType = z.enum(["icon", "letters", "image"]);
export const AvatarData = z.discriminatedUnion("type", [
z.object({
type: z.literal(AvatarType.enum.icon),
name: z.string(),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.letters),
hex: z.string(),
}),
z.object({
type: z.literal(AvatarType.enum.image),
url: z.string().url(),
}),
]);
export type Avatar = z.infer<typeof AvatarData>;
export type IconAvatar = Extract<Avatar, { type: "icon" }>;
export type ImageAvatar = Extract<Avatar, { type: "image" }>;
export type LettersAvatar = Extract<Avatar, { type: "letters" }>;
export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avatar {
if (!json || typeof json !== "object") {
return defaultAvatar;
}
const parsed = AvatarData.safeParse(json);
if (!parsed.success) {
logger.error("Invalid org avatar", { json, error: parsed.error });
return defaultAvatar;
}
return parsed.data;
}
export function Avatar({
avatar,
className,
includePadding,
}: {
avatar: Avatar;
className?: string;
includePadding?: boolean;
}) {
switch (avatar.type) {
case "icon":
return <AvatarIcon avatar={avatar} className={className} includePadding={includePadding} />;
case "letters":
return (
<AvatarLetters avatar={avatar} className={className} includePadding={includePadding} />
);
case "image":
return <AvatarImage avatar={avatar} className={className} />;
}
}
export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
"hero:building-office-2": BuildingOffice2Icon,
"hero:rocket-launch": RocketLaunchIcon,
"hero:code-bracket-square": CodeBracketSquareIcon,
"hero:fire": FireIcon,
"hero:star": StarIcon,
"hero:face-smile": FaceSmileIcon,
};
export const defaultAvatarColors = [
{ hex: "#878C99", name: "Gray" },
{ hex: "#713F12", name: "Brown" },
{ hex: "#F97316", name: "Orange" },
{ hex: "#EAB308", name: "Yellow" },
{ hex: "#22C55E", name: "Green" },
{ hex: "#3B82F6", name: "Blue" },
{ hex: "#6366F1", name: "Purple" },
{ hex: "#EC4899", name: "Pink" },
{ hex: "#F43F5E", name: "Red" },
];
// purple
export const defaultAvatarHex = defaultAvatarColors[6].hex;
export const defaultAvatar: Avatar = {
type: "letters",
hex: defaultAvatarHex,
};
function AvatarLetters({
avatar,
className,
includePadding,
}: {
avatar: LettersAvatar;
className?: string;
includePadding?: boolean;
}) {
const organization = useOrganization();
const containerRef = useRef<HTMLSpanElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [fontSize, setFontSize] = useState("1rem");
useLayoutEffect(() => {
if (containerRef.current) {
const containerWidth = containerRef.current.offsetWidth;
// Set font size to 60% of container width (adjust as needed)
setFontSize(`${containerWidth * 0.6}px`);
}
// Optional: Create a ResizeObserver for dynamic resizing
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === containerRef.current) {
const containerWidth = entry.contentRect.width;
setFontSize(`${containerWidth * 0.6}px`);
}
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const letters = organization.title.slice(0, 2);
const classes = cn("grid place-items-center", className);
const style = {
backgroundColor: avatar.hex,
};
return (
<span className={cn("grid place-items-center overflow-hidden text-charcoal-750", classes)}>
{/* This is the square container */}
<span
ref={containerRef}
className={cn(
"relative grid place-items-center overflow-hidden rounded-[10%] font-semibold",
includePadding ? "size-[80%]" : "size-[100%]"
)}
style={style}
>
<span ref={textRef} className="font-bold leading-none" style={{ fontSize }}>
{letters}
</span>
</span>
</span>
);
}
function AvatarIcon({
avatar,
className,
includePadding,
}: {
avatar: IconAvatar;
className?: string;
includePadding?: boolean;
}) {
const classes = cn("aspect-square", className);
const style = {
color: avatar.hex,
};
const IconComponent = avatarIcons[avatar.name];
return (
<span className={cn("grid place-items-center", classes)}>
<IconComponent className={includePadding ? "size-[80%]" : "size-[100%]"} style={style} />
</span>
);
}
function AvatarImage({ avatar, className }: { avatar: ImageAvatar; className?: string }) {
return (
<span className="grid place-items-center">
<img src={avatar.url} alt="Organization avatar" className="size-6" />
</span>
);
}
@@ -1,9 +1,10 @@
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
import React, { forwardRef, ReactNode, useImperativeHandle, useRef } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react";
import React, { forwardRef, type ReactNode, useImperativeHandle, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
import { Icon, type RenderIcon } from "./Icon";
const sizes = {
small: {
@@ -164,8 +165,8 @@ const allVariants = {
export type ButtonContentPropsType = {
children?: React.ReactNode;
LeadingIcon?: React.ComponentType<any>;
TrailingIcon?: React.ComponentType<any>;
LeadingIcon?: RenderIcon;
TrailingIcon?: RenderIcon;
trailingIconClassName?: string;
leadingIconClassName?: string;
fullWidth?: boolean;
@@ -220,7 +221,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
)}
>
{LeadingIcon && (
<LeadingIcon
<Icon
icon={LeadingIcon}
className={cn(
iconClassName,
variation.icon,
@@ -245,7 +247,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
renderShortcutKey()}
{TrailingIcon && (
<TrailingIcon
<Icon
icon={TrailingIcon}
className={cn(
iconClassName,
variation.icon,
@@ -1,4 +1,4 @@
import React, { FunctionComponent, createElement } from "react";
import React, { type FunctionComponent, createElement } from "react";
import { cn } from "~/utils/cn";
export type RenderIcon = FunctionComponent<{ className?: string }> | React.ReactNode;
@@ -1,14 +1,16 @@
"use client";
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import { CheckIcon } from "@heroicons/react/20/solid";
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 * as useShortcutKeys from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
import { Paragraph, type ParagraphVariant } from "./Paragraph";
import { ShortcutKey } from "./ShortcutKey";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { CheckIcon } from "@heroicons/react/20/solid";
import { type RenderIcon } from "./Icon";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -38,7 +40,7 @@ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
function PopoverSectionHeader({
title,
variant = "extra-extra-small/dimmed/caps",
variant = "extra-small",
}: {
title: string;
variant?: ParagraphVariant;
@@ -59,7 +61,7 @@ function PopoverMenuItem({
leadingIconClassName,
}: {
to: string;
icon: React.ComponentType<any>;
icon?: RenderIcon;
title: React.ReactNode;
isSelected?: boolean;
variant?: ButtonContentPropsType;
@@ -109,11 +111,12 @@ function PopoverSideMenuTrigger({
className,
shortcut,
...props
}: { isOpen?: boolean; shortcut?: ShortcutDefinition } & React.ComponentPropsWithoutRef<
typeof PopoverTrigger
>) {
}: {
isOpen?: boolean;
shortcut?: useShortcutKeys.ShortcutDefinition;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys({
useShortcutKeys.useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
@@ -158,7 +161,7 @@ function PopoverArrowTrigger({
<PopoverTrigger
{...props}
className={cn(
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
fullWidth && "w-full justify-between",
className
)}
@@ -172,10 +175,10 @@ function PopoverArrowTrigger({
>
{children}
</Paragraph>
<ChevronDownIcon
<DropdownIcon
className={cn(
"h-3 w-3 min-w-[0.75rem] text-charcoal-600 transition group-hover:text-text-bright",
isOpen && "-rotate-180"
"h-4 w-4 min-w-[0.75rem] text-text-dimmed transition group-hover:text-text-bright",
isOpen && "-scale-y-100"
)}
/>
</PopoverTrigger>
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
return (
<div
className={cn(
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 bg-charcoal-700 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
boxClassName
)}
>
@@ -1,12 +1,12 @@
import { Link } from "@remix-run/react";
import { cn } from "~/utils/cn";
import { Icon, RenderIcon } from "./Icon";
import { Icon, type RenderIcon } from "./Icon";
const variations = {
primary:
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
secondary:
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group focus-visible:focus-custom",
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
} as const;
type TextLinkProps = {
@@ -59,6 +59,7 @@ function SimpleTooltip({
disableHoverableContent = false,
className,
buttonClassName,
asChild = false,
}: {
button: React.ReactNode;
content: React.ReactNode;
@@ -68,11 +69,12 @@ function SimpleTooltip({
disableHoverableContent?: boolean;
className?: string;
buttonClassName?: string;
asChild?: boolean;
}) {
return (
<TooltipProvider disableHoverableContent={disableHoverableContent}>
<Tooltip>
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)}>
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)} asChild={asChild}>
{button}
</TooltipTrigger>
<TooltipContent
@@ -38,12 +38,10 @@ import {
} from "./BatchStatus";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
@@ -52,10 +50,6 @@ export const BatchStatus = z.enum(allBatchStatuses);
export const BatchListFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
environments: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
statuses: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
BatchStatus.array().optional()
@@ -68,12 +62,7 @@ export const BatchListFilters = z.object({
export type BatchListFilters = z.infer<typeof BatchListFilters>;
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type BatchFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
hasFilters: boolean;
};
@@ -82,7 +71,6 @@ export function BatchFilters(props: BatchFiltersProps) {
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("id") ||
searchParams.has("period") ||
searchParams.has("from") ||
@@ -91,7 +79,7 @@ export function BatchFilters(props: BatchFiltersProps) {
return (
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<AppliedFilters {...props} />
<AppliedFilters />
{hasFilters && (
<Form className="h-6">
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
@@ -113,7 +101,6 @@ const filterTypes = [
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
@@ -157,11 +144,10 @@ function FilterMenu(props: BatchFiltersProps) {
);
}
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
function AppliedFilters() {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedPeriodFilter />
<AppliedCustomDateRangeFilter />
<AppliedBatchIdFilter />
@@ -183,8 +169,6 @@ function Menu(props: MenuProps) {
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "daterange":
@@ -1,9 +1,9 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation, useSubmit } from "@remix-run/react";
import { useCallback, useEffect, useRef } from "react";
import { UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
import { JSONEditor } from "~/components/code/JSONEditor";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Button } from "~/components/primitives/Buttons";
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
@@ -135,7 +135,7 @@ function ReplayForm({
const env = environments.find((env) => env.id === value)!;
return (
<div className="flex items-center pl-1 pr-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<EnvironmentCombo environment={env} />
</div>
);
}}
@@ -143,7 +143,7 @@ function ReplayForm({
{(matches) =>
matches.map((env) => (
<SelectItem key={env.id} value={env.id}>
<EnvironmentLabel environment={env} userName={env.userName} />
<EnvironmentCombo environment={env} />
</SelectItem>
))
}
@@ -9,16 +9,10 @@ import {
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, useFetcher } from "@remix-run/react";
import type {
BulkActionType,
RuntimeEnvironment,
TaskRunStatus,
TaskTriggerSource,
} from "@trigger.dev/database";
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { ListChecks, ListFilterIcon } from "lucide-react";
import { matchSorter } from "match-sorter";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
@@ -53,12 +47,10 @@ import { Button } from "../../primitives/Buttons";
import { BulkActionStatusCombo } from "./BulkAction";
import {
AppliedCustomDateRangeFilter,
AppliedEnvironmentFilter,
AppliedPeriodFilter,
appliedSummary,
CreatedAtDropdown,
CustomDateRangeDropdown,
EnvironmentsDropdown,
FilterMenuProvider,
} from "./SharedFilters";
import {
@@ -107,12 +99,7 @@ export const TaskRunListSearchFilters = z.object({
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type RunFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
bulkActions: {
id: string;
@@ -128,7 +115,6 @@ export function RunsFilters(props: RunFiltersProps) {
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("tasks") ||
searchParams.has("period") ||
searchParams.has("bulkId") ||
@@ -168,7 +154,6 @@ const filterTypes = [
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
@@ -217,11 +202,10 @@ function FilterMenu(props: RunFiltersProps) {
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedTagsFilter />
<AppliedPeriodFilter />
@@ -248,8 +232,7 @@ function Menu(props: MenuProps) {
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
@@ -6,7 +6,6 @@ import { z } from "zod";
import { Input } from "~/components/primitives/Input";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useThrottle } from "~/hooks/useThrottle";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import {
@@ -37,16 +36,11 @@ export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
const All = "ALL";
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type ScheduleFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: string[];
};
export function ScheduleFilters({ possibleEnvironments, possibleTasks }: ScheduleFiltersProps) {
export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) {
const navigate = useNavigate();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
@@ -54,8 +48,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
Object.fromEntries(searchParams.entries())
);
const hasFilters =
searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search");
const hasFilters = searchParams.has("tasks") || searchParams.has("search");
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
@@ -71,10 +64,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleTypeChange = useCallback((value: string | typeof All) => {
handleFilterChange("type", value === "ALL" ? undefined : value);
}, []);
@@ -87,7 +76,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
searchParams.delete("page");
searchParams.delete("enabled");
searchParams.delete("tasks");
searchParams.delete("environments");
searchParams.delete("search");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
@@ -127,39 +115,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
</Select>
</SelectGroup>
<SelectGroup>
<Select
name="environment"
value={environments?.at(0) ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="minimal" width="full">
<SelectValue
placeholder={"Select environment"}
className="ml-2 whitespace-nowrap p-0"
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
All environments
</Paragraph>
</SelectItem>
{possibleEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
@@ -100,7 +100,7 @@ export function EnvironmentsDropdown({
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
<EnvironmentLabel environment={item} />
</SelectItem>
))}
</SelectList>
@@ -11,7 +11,7 @@ import {
TrashIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { TaskRunStatus } from "@trigger.dev/database";
import { type TaskRunStatus } from "@trigger.dev/database";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
@@ -18,15 +18,16 @@ import { Header3 } from "~/components/primitives/Headers";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironments } from "~/hooks/useEnvironments";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server";
import {
type RunListAppliedFilters,
type RunListItem,
} from "~/presenters/v3/RunListPresenter.server";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
import { Spinner } from "../../primitives/Spinner";
@@ -39,7 +40,7 @@ import {
TableHeader,
TableHeaderCell,
TableRow,
TableVariant,
type TableVariant,
} from "../../primitives/Table";
import { CancelRunDialog } from "./CancelRunDialog";
import { LiveTimer } from "./LiveTimer";
@@ -50,6 +51,7 @@ import {
filterableTaskRunStatuses,
TaskRunStatusCombo,
} from "./TaskRunStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
type RunsTableProps = {
total: number;
@@ -74,6 +76,7 @@ export function TaskRunsTable({
const user = useUser();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
const { isManagedCloud } = useFeatures();
@@ -133,7 +136,6 @@ export function TaskRunsTable({
</TableHeaderCell>
)}
<TableHeaderCell alignment="right">Run #</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell
@@ -279,14 +281,16 @@ export function TaskRunsTable({
</TableHeader>
<TableBody>
{total === 0 && !hasFilters ? (
<TableBlankRow colSpan={14}>
<TableBlankRow colSpan={15}>
{!isLoading && <NoRuns title="No runs found" />}
</TableBlankRow>
) : runs.length === 0 ? (
<BlankState isLoading={isLoading} filters={filters} />
) : (
runs.map((run, index) => {
const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId });
const path = v3RunSpanPath(organization, project, environment, run, {
spanId: run.spanId,
});
return (
<TableRow key={run.id}>
{allowSelection && (
@@ -306,12 +310,6 @@ export function TaskRunsTable({
<TableCell to={path} alignment="right" isTabbableCell>
{formatNumber(run.number)}
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={run.environment}
userName={run.environment.userName}
/>
</TableCell>
<TableCell to={path}>
<span className="flex items-center gap-x-1">
{run.taskIdentifier}
@@ -396,7 +394,7 @@ export function TaskRunsTable({
)}
{isLoading && (
<TableBlankRow
colSpan={14}
colSpan={15}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
@@ -535,62 +533,47 @@ function NoRuns({ title }: { title: string }) {
function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "filters">) {
const organization = useOrganization();
const project = useProject();
const envs = useEnvironments();
if (isLoading) return <TableBlankRow colSpan={14}></TableBlankRow>;
const environment = useEnvironment();
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
const { environments, tasks, from, to, ...otherFilters } = filters;
if (
filters.environments.length === 1 &&
filters.tasks.length === 1 &&
filters.from === undefined &&
filters.to === undefined &&
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
) {
const environment = envs?.find((env) => env.id === filters.environments[0]);
return (
<TableBlankRow colSpan={14}>
<div className="py-14">
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
{environment ? (
<>
{" "}
in{" "}
<EnvironmentLabel
environment={environment}
userName={environment.userName}
size="large"
/>
</>
) : null}
</Paragraph>
<div className="flex items-center justify-center gap-2">
<LinkButton
to={v3TestPath(organization, project)}
variant="primary/small"
LeadingIcon={BeakerIcon}
className="inline-flex"
>
Create a test run
</LinkButton>
<Paragraph variant="small">or</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="primary/small"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Triggering a task docs
</LinkButton>
</div>
<TableBlankRow colSpan={15}>
<Paragraph className="w-auto" variant="base/bright" spacing>
There are no runs for {filters.tasks[0]}
</Paragraph>
<div className="mt-6 flex items-center justify-center gap-2">
<LinkButton
to={v3TestPath(organization, project, environment)}
variant="tertiary/medium"
LeadingIcon={BeakerIcon}
className="inline-flex"
>
Create a test run
</LinkButton>
<Paragraph variant="small">or</Paragraph>
<LinkButton
to={docsPath("v3/triggering")}
variant="tertiary/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
Triggering a task docs
</LinkButton>
</div>
</TableBlankRow>
);
}
return (
<TableBlankRow colSpan={14}>
<TableBlankRow colSpan={15}>
<div className="flex flex-col items-center justify-center gap-6">
<Paragraph className="w-auto" variant="base/bright">
No runs match your filters. Try refreshing, modifying your filters or run a test.
@@ -609,7 +592,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
<LinkButton
LeadingIcon={BeakerIcon}
variant="tertiary/medium"
to={v3TestPath(organization, project)}
to={v3TestPath(organization, project, environment)}
>
Run a test
</LinkButton>
+23
View File
@@ -0,0 +1,23 @@
import { type UIMatch } from "@remix-run/react";
import { type UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { organizationMatchId } from "./useOrganizations";
import { useTypedMatchesData } from "./useTypedMatchData";
export type MatchedEnvironment = UseDataFunctionReturn<typeof orgLoader>["environment"];
export function useOptionalEnvironment(matches?: UIMatch[]) {
const routeMatch = useTypedMatchesData<typeof orgLoader>({
id: organizationMatchId,
matches,
});
return routeMatch?.environment;
}
export function useEnvironment(matches?: UIMatch[]) {
const environment = useOptionalEnvironment(matches);
invariant(environment, "Environment must be defined");
return environment;
}
@@ -0,0 +1,94 @@
import { type Path, useMatches } from "@remix-run/react";
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { useEnvironment } from "./useEnvironment";
import { useEnvironments } from "./useEnvironments";
import { useOptimisticLocation } from "./useOptimisticLocation";
/**
* It gives the URLs for the current page for other environments
* @returns
*/
export function useEnvironmentSwitcher() {
const matches = useMatches();
const location = useOptimisticLocation();
const urlForEnvironment = (newEnvironment: MinimumEnvironment) => {
return routeForEnvironmentSwitch({
location,
matchId: matches[matches.length - 1].id,
environmentSlug: newEnvironment.slug,
});
};
return {
urlForEnvironment,
};
}
/** Function that takes in a UIMatch id, the current URL, the new environment slug, and returns a new URL */
export function routeForEnvironmentSwitch({
location,
matchId,
environmentSlug,
}: {
location: Path;
matchId: string;
environmentSlug: string;
}) {
switch (matchId) {
// Run page
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam": {
const newLocation: Path = {
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
/\/runs\/.*/,
"/runs"
),
search: "",
hash: "",
};
return fullPath(newLocation);
}
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam": {
const newLocation: Path = {
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
/\/deployments\/.*/,
"/deployments"
),
search: "",
hash: "",
};
return fullPath(newLocation);
}
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam":
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam": {
const newLocation: Path = {
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
/\/schedules\/.*/,
"/schedules"
),
search: "",
hash: "",
};
return fullPath(newLocation);
}
default: {
const newLocation: Path = {
pathname: replaceEnvInPath(location.pathname, environmentSlug),
search: location.search,
hash: location.hash,
};
return fullPath(newLocation);
}
}
}
/**
* Replace the /env/<slug>/ in the path so it's /env/<environmentSlug>
*/
function replaceEnvInPath(path: string, environmentSlug: string) {
return path.replace(/env\/([a-z0-9-]+)/, `env/${environmentSlug}`);
}
function fullPath(location: Path) {
return `${location.pathname}${location.search}${location.hash}`;
}
+3 -3
View File
@@ -23,12 +23,12 @@ export function useEventSource(
return;
}
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
// reset data if dependencies change
setData(null);
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
function handler(event: MessageEvent) {
setData(event.data || "UNKNOWN_EVENT_DATA");
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useTypedRouteLoaderData } from "remix-typedjson";
import { loader } from "../root";
import { type loader } from "../root";
import type { TriggerFeatures } from "~/features.server";
export function useFeatures(): TriggerFeatures {
+2 -2
View File
@@ -1,5 +1,5 @@
import { UIMatch } from "@remix-run/react";
import { UseDataFunctionReturn } from "remix-typedjson";
import { type UIMatch } from "@remix-run/react";
import { type UseDataFunctionReturn } from "remix-typedjson";
import invariant from "tiny-invariant";
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
import { useChanged } from "./useChanged";
-16
View File
@@ -1,16 +0,0 @@
import { useSyncTrace } from "./useSyncTrace";
import { useSyncTraceRuns } from "./useSyncTraceRuns";
type Params = {
origin: string;
traceId: string;
};
export function useSyncRunPage({ origin, traceId }: Params) {
const { runs } = useSyncTraceRuns({ origin, traceId });
const { events } = useSyncTrace({ origin, traceId });
const isUpToDate = runs !== undefined && events !== undefined;
return { isUpToDate, runs, events };
}
-19
View File
@@ -1,19 +0,0 @@
import { useShape } from "@electric-sql/react";
import { TaskEvent } from "@trigger.dev/database";
import { createTraceTreeFromEvents } from "~/utils/taskEvent";
type Params = {
origin: string;
traceId: string;
};
export type Trace = ReturnType<typeof createTraceTreeFromEvents>;
export type TraceEvent = Trace["events"][number];
export function useSyncTrace({ origin, traceId }: Params) {
const { data, error, isError } = useShape({
url: `${origin}/sync/traces/${traceId}`,
});
return { error, isError, events: data ? (data as any as TaskEvent[]) : undefined };
}
-18
View File
@@ -1,18 +0,0 @@
import type { Prettify } from "@trigger.dev/core";
import { TaskRun } from "@trigger.dev/database";
import { SyncedShapeData, useSyncedShape } from "./useSyncedShape";
type Params = {
origin: string;
traceId: string;
};
export type RawRun = Prettify<SyncedShapeData<TaskRun>>;
export function useSyncTraceRuns({ origin, traceId }: Params) {
const { data, error, isError } = useSyncedShape<TaskRun>({
url: `${origin}/sync/traces/runs/${traceId}`,
});
return { runs: data, error, isError };
}
-71
View File
@@ -1,71 +0,0 @@
import { useShape } from "@electric-sql/react";
export type ShapeInput = Parameters<typeof useShape>[0];
export type ShapeOutput<S> = {
error: Error | false;
isError: boolean;
data: S[] | undefined;
};
export type SyncedShapeData<T> = {
[K in keyof T]: T[K] extends Date
? string
: T[K] extends Date | null
? string | null
: T[K] extends BigInt
? number
: T[K] extends BigInt | null
? number | null
: T[K] extends object
? SyncedShapeData<T[K]>
: T[K];
};
export function useSyncedShape<S>(props: ShapeInput): ShapeOutput<SyncedShapeData<S>> {
const output = useShape(props) as any;
return {
error: output.error,
isError: output.isError,
data: output.data
? (transformInput(output.data as InputObject) as SyncedShapeData<S>[])
: undefined,
};
}
type InputObject = {
[key: string]: Value;
}[];
type Value =
| string
| number
| boolean
| bigint
| null
| Value[]
| {
[key: string]: Value;
};
function transformInput(input: InputObject): SyncedShapeData<Value> {
return input.map((value) => transformValue(value));
}
function transformValue(value: Value): Value {
if (Array.isArray(value)) {
return value.map(transformValue);
} else if (typeof value === "object" && value !== null) {
const result: { [key: string]: Value } = {};
for (const key in value) {
result[key] = transformValue(value[key]);
}
return result;
} else if (typeof value === "bigint") {
return Number(value);
} else {
return value;
}
}
@@ -69,6 +69,28 @@ export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvi
return environment;
}
export async function findEnvironmentBySlug(projectId: string, envSlug: string, userId: string) {
return prisma.runtimeEnvironment.findFirst({
where: {
projectId: projectId,
slug: envSlug,
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
},
});
}
export async function findEnvironmentFromRun(
runId: string,
tx?: PrismaClientOrTransaction
+20 -1
View File
@@ -2,6 +2,10 @@ import type { Prisma, User } from "@trigger.dev/database";
import type { GitHubProfile } from "remix-auth-github";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import {
DashboardPreferences,
getDashboardPreferences,
} from "~/services/dashboardPreferences.server";
export type { User } from "@trigger.dev/database";
type FindOrCreateMagicLink = {
@@ -158,8 +162,23 @@ export async function findOrCreateGithubUser({
};
}
export type UserWithDashboardPreferences = User & {
dashboardPreferences: DashboardPreferences;
};
export async function getUserById(id: User["id"]) {
return prisma.user.findUnique({ where: { id } });
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return null;
}
const dashboardPreferences = getDashboardPreferences(user.dashboardPreferences);
return {
...user,
dashboardPreferences,
};
}
export async function getUserByEmail(email: User["email"]) {
@@ -1,15 +1,15 @@
import { PrismaClient } from "@trigger.dev/database";
import { type PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import {
commitCurrentProjectSession,
getCurrentProjectId,
setCurrentProjectId,
} from "~/services/currentProject.server";
import { logger } from "~/services/logger.server";
import { newProjectPath } from "~/utils/pathBuilder";
import { ProjectPresenter } from "./ProjectPresenter.server";
import { type UserFromSession } from "~/services/session.server";
import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
import {
SelectBestEnvironmentPresenter,
type MinimumEnvironment,
} from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -19,25 +19,27 @@ export class OrganizationsPresenter {
}
public async call({
userId,
user,
organizationSlug,
projectSlug,
environmentSlug,
request,
}: {
userId: string;
user: UserFromSession;
organizationSlug: string;
projectSlug: string | undefined;
environmentSlug: string | undefined;
request: Request;
}) {
//first get the project id, this redirects if there's no session
const projectId = await this.#getProjectId({
request,
projectSlug,
organizationSlug,
userId,
});
const organizations = await this.#getOrganizations(user.id);
if (organizations.length === 0) {
logger.info("No organizations", {
organizationSlug,
request,
});
throw redirect(newOrganizationPath());
}
const organizations = await this.#getOrganizations(userId);
const organization = organizations.find((o) => o.slug === organizationSlug);
if (!organization) {
logger.info("Not Found: organization", {
@@ -45,130 +47,75 @@ export class OrganizationsPresenter {
request,
organization,
});
throw new Response("Not Found", { status: 404 });
throw new Response("Organization not Found", { status: 404 });
}
const projectPresenter = new ProjectPresenter(this.#prismaClient);
const project = await projectPresenter.call({
id: projectId,
userId,
const selector = new SelectBestEnvironmentPresenter();
const bestProject = await selector.selectBestProjectFromProjects({
user,
projectSlug,
projects: organization.projects,
});
if (!project) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
if (!bestProject) {
logger.info("Not Found: project", {
projectSlug,
request,
"No projects found in organization"
);
}
if (project.organizationId !== organization.id) {
throw redirect(newProjectPath({ slug: organizationSlug }), request);
}
return { organizations, organization, project };
}
async #getProjectId({
request,
projectSlug,
organizationSlug,
userId,
}: {
request: Request;
projectSlug: string | undefined;
organizationSlug: string;
userId: string;
}): Promise<string> {
const sessionProjectId = await getCurrentProjectId(request);
//no project in session, let's set one
if (!sessionProjectId) {
//no session id and no project slug so we need to select the best project
if (!projectSlug) {
const bestProject = await this.#selectBestProjectForOrganization(
organizationSlug,
userId,
request
);
const session = await setCurrentProjectId(bestProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
});
}
//get all the projects
const projects = await prisma.project.findMany({
select: {
id: true,
slug: true,
},
where: {
organization: {
slug: organizationSlug,
},
deletedAt: null,
slug: projectSlug,
},
orderBy: {
updatedAt: "desc",
},
});
if (projects.length === 0) {
throw redirectWithErrorMessage(
newProjectPath({ slug: organizationSlug }),
request,
"No projects in this organization"
);
}
//try get the project which matches the URL
let matchingProject = projects.find((p) => p.slug === projectSlug);
//if there's no matching project, just use the most recently updated one
if (!matchingProject) {
matchingProject = projects[0];
}
//set the session
const session = await setCurrentProjectId(matchingProject.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
project: bestProject,
});
throw redirect(newProjectPath(organization));
}
if (!projectSlug) {
return sessionProjectId;
}
//check session id matches the project slug
const project = await prisma.project.findFirst({
select: {
id: true,
slug: true,
},
const fullProject = await this.#prismaClient.project.findFirst({
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
id: bestProject.id,
},
include: {
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
deletedAt: null,
},
});
if (!project) {
throw new Response("Project not found in organization", { status: 404 });
}
if (project.id !== sessionProjectId) {
const session = await setCurrentProjectId(project.id, request);
throw redirect(request.url, {
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
if (!fullProject) {
logger.info("Not Found: project", {
projectSlug,
request,
project: bestProject,
});
throw redirect(newProjectPath(organization));
}
return project.id;
const environment = this.#getEnvironment({
user,
projectId: fullProject.id,
environments: fullProject.environments,
environmentSlug,
});
return {
organizations,
organization,
project: {
...fullProject,
environments: sortEnvironments(
fullProject.environments.filter((env) => {
if (env.type !== "DEVELOPMENT") return true;
if (env.orgMember?.userId === user.id) return true;
return false;
})
),
},
environment,
};
}
async #getOrganizations(userId: string) {
@@ -179,17 +126,22 @@ export class OrganizationsPresenter {
id: true,
slug: true,
title: true,
runsEnabled: true,
avatar: true,
projects: {
where: { deletedAt: null },
where: { deletedAt: null, version: "V3" },
select: {
id: true,
slug: true,
name: true,
version: true,
updatedAt: true,
},
orderBy: { name: "asc" },
},
_count: {
select: {
members: true,
},
},
},
});
@@ -198,47 +150,65 @@ export class OrganizationsPresenter {
id: org.id,
slug: org.slug,
title: org.title,
avatar: parseAvatar(org.avatar, defaultAvatar),
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
name: project.name,
version: project.version,
updatedAt: project.updatedAt,
})),
runsEnabled: org.runsEnabled,
membersCount: org._count.members,
};
});
}
async #selectBestProjectForOrganization(
organizationSlug: string,
userId: string,
request: Request
) {
const projects = await this.#prismaClient.project.findMany({
select: {
id: true,
slug: true,
},
where: {
deletedAt: null,
organization: {
deletedAt: null,
slug: organizationSlug,
members: { some: { userId } },
},
},
orderBy: {
jobs: {
_count: "desc",
},
},
take: 1,
});
#getEnvironment({
user,
projectId,
environmentSlug,
environments,
}: {
user: UserFromSession;
projectId: string;
environmentSlug: string | undefined;
environments: MinimumEnvironment[];
}) {
if (environmentSlug) {
const env = environments.find((e) => e.slug === environmentSlug);
if (env) {
return env;
}
if (projects.length === 0) {
throw redirect(newProjectPath({ slug: organizationSlug }), request);
if (!env) {
logger.info("Not Found: environment", {
environmentSlug,
environments,
});
}
}
return projects[0];
const currentEnvironmentId: string | undefined =
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
const environment = environments.find((e) => e.id === currentEnvironmentId);
if (environment) {
return environment;
}
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
}
//otherwise show their prod environment
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
if (prodEnvironment) {
return prodEnvironment;
}
throw new Error("No environments found");
}
}
@@ -0,0 +1,166 @@
import { type RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { type UserFromSession } from "~/services/session.server";
export type MinimumEnvironment = Pick<RuntimeEnvironment, "id" | "type" | "slug"> & {
orgMember: null | {
userId: string | undefined;
};
};
export class SelectBestEnvironmentPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ user }: { user: UserFromSession }) {
const { project, organization } = await this.getBestProject(user);
const environment = await this.selectBestEnvironment(project.id, user, project.environments);
return {
project,
organization,
environment,
};
}
async getBestProject(user: UserFromSession) {
//try get current project from cookie
const projectId = user.dashboardPreferences.currentProjectId;
if (projectId) {
const project = await this.#prismaClient.project.findFirst({
where: {
id: projectId,
deletedAt: null,
organization: { members: { some: { userId: user.id } } },
},
include: {
organization: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (project) {
return { project, organization: project.organization };
}
}
//failing that, we pick the most recently modified project
const projects = await this.#prismaClient.project.findMany({
include: {
organization: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
where: {
deletedAt: null,
organization: {
members: { some: { userId: user.id } },
},
},
orderBy: {
updatedAt: "desc",
},
take: 1,
});
if (projects.length === 0) {
throw new Response("Not Found", { status: 404 });
}
return { project: projects[0], organization: projects[0].organization };
}
async selectBestProjectFromProjects({
user,
projectSlug,
projects,
}: {
user: UserFromSession;
projectSlug: string | undefined;
projects: {
id: string;
slug: string;
name: string;
updatedAt: Date;
}[];
}) {
if (projectSlug) {
const proj = projects.find((p) => p.slug === projectSlug);
if (proj) {
return proj;
}
if (!proj) {
logger.info("Not Found: project", {
projectSlug,
projects,
});
}
}
const currentProjectId = user.dashboardPreferences.currentProjectId;
const project = projects.find((p) => p.id === currentProjectId);
if (project) {
return project;
}
//most recently updated
return projects.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).at(0);
}
async selectBestEnvironment(
projectId: string,
user: UserFromSession,
environments: MinimumEnvironment[]
): Promise<MinimumEnvironment> {
//try get current environment from prefs
const currentEnvironmentId: string | undefined =
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
if (currentEnvironment) {
return currentEnvironment;
}
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
}
//otherwise show their prod environment
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
if (prodEnvironment) {
return prodEnvironment;
}
throw new Error("No environments found");
}
}
@@ -1,48 +0,0 @@
import { PrismaClient } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { getCurrentProjectId } from "~/services/currentProject.server";
export class SelectBestProjectPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({ userId, request }: { userId: string; request: Request }) {
//try get current project from cookie
const projectId = await getCurrentProjectId(request);
if (projectId) {
const project = await this.#prismaClient.project.findUnique({
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
include: { organization: true },
});
if (project) {
return { project, organization: project.organization };
}
}
//failing that, we pick the most recently modified project
const projects = await this.#prismaClient.project.findMany({
include: {
organization: true,
},
where: {
deletedAt: null,
organization: {
members: { some: { userId } },
},
},
orderBy: {
updatedAt: "desc",
},
take: 1,
});
if (projects.length === 0) {
throw new Response("Not Found", { status: 404 });
}
return { project: projects[0], organization: projects[0].organization };
}
}
@@ -1,6 +1,6 @@
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { type ProjectAlertChannel } from "@trigger.dev/database";
import { RuntimeEnvironmentType, type ProjectAlertChannel } from "@trigger.dev/database";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { env } from "~/env.server";
import {
@@ -18,7 +18,7 @@ export type AlertChannelListPresenterAlertProperties = NonNullable<
>;
export class AlertChannelListPresenter extends BasePresenter {
public async call(projectId: string) {
public async call(projectId: string, environmentType?: RuntimeEnvironmentType) {
logger.debug("AlertChannelListPresenter", { projectId });
const alertChannels = await this._prisma.projectAlertChannel.findMany({
@@ -45,9 +45,14 @@ export class AlertChannelListPresenter extends BasePresenter {
const limit = await getLimit(organization.organizationId, "alerts", 100_000_000);
const relevantChannels = alertChannels.filter((channel) => {
if (!environmentType) return true;
return channel.environmentTypes.includes(environmentType);
});
return {
alertChannels: await Promise.all(
alertChannels.map(async (alertChannel) => ({
relevantChannels.map(async (alertChannel) => ({
...alertChannel,
properties: await this.#presentProperties(alertChannel),
}))
@@ -1,6 +1,6 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ApiKeysPresenter {
@@ -33,11 +33,6 @@ export class ApiKeysPresenter {
version: "desc",
},
},
_count: {
select: {
environmentVariableValues: true,
},
},
},
where: {
project: {
@@ -71,7 +66,6 @@ export class ApiKeysPresenter {
slug: environment.slug,
updatedAt: environment.updatedAt,
latestVersion: environment.backgroundWorkers.at(0)?.version,
environmentVariableCount: environment._count.environmentVariableValues,
}))
),
hasStaging: environments.some((environment) => environment.type === "STAGING"),
@@ -1,9 +1,9 @@
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import { type BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
import parse from "parse-duration";
import { sqlDatabaseSchema } from "~/db.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { BasePresenter } from "./basePresenter.server";
import { Direction } from "~/components/ListPagination";
import { type Direction } from "~/components/ListPagination";
export type BatchListOptions = {
userId?: string;
@@ -45,7 +45,6 @@ export class BatchListPresenter extends BasePresenter {
const hasFilters =
hasStatusFilters ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
friendlyId !== undefined ||
from !== undefined ||
@@ -6,10 +6,11 @@ import {
type DisplayableInputEnvironment,
} from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getLimit } from "~/services/platform.v3.server";
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
import { BasePresenter } from "./basePresenter.server";
import { execute } from "effect/Stream";
import { engine } from "~/v3/runEngine.server";
export type Environment = Awaited<
ReturnType<ConcurrencyPresenter["environmentConcurrency"]>
@@ -56,6 +57,7 @@ export class ConcurrencyPresenter extends BasePresenter {
return {
environments: this.environmentConcurrency(
project.organizationId,
project.id,
userId,
filterOrphanedEnvironments(project.environments)
@@ -64,15 +66,41 @@ export class ConcurrencyPresenter extends BasePresenter {
}
async environmentConcurrency(
organizationId: string,
projectId: string,
userId: string,
environments: (DisplayableInputEnvironment & { maximumConcurrencyLimit: number })[]
) {
const environmentConcurrency = await concurrencyTracker.environmentConcurrentRunCounts(
const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts(
projectId,
environments.map((env) => env.id)
);
const engineV2Concurrency = await Promise.all(
environments.map(async (env) =>
engine.currentConcurrencyOfEnvQueue({
...env,
project: {
id: projectId,
},
organization: {
id: organizationId,
},
})
)
);
//Build `executingCounts` with both v1 and v2 concurrencies
const executingCounts: Record<string, number> = engineV1Concurrency;
for (let index = 0; index < environments.length; index++) {
const env = environments[index];
const existingValue: number | undefined = executingCounts[env.id];
executingCounts[env.id] = engineV2Concurrency[index] + (existingValue ?? 0);
}
//todo add Run Engine 2 concurrency count
const queued = await this._replica.$queryRaw<
{
runtimeEnvironmentId: string;
@@ -93,7 +121,7 @@ GROUP BY
const sortedEnvironments = sortEnvironments(environments).map((environment) => ({
...displayableEnvironment(environment, userId),
concurrencyLimit: environment.maximumConcurrencyLimit,
concurrency: environmentConcurrency[environment.id] ?? 0,
concurrency: executingCounts[environment.id] ?? 0,
queued: Number(queued.find((q) => q.runtimeEnvironmentId === environment.id)?.count ?? 0),
}));
@@ -1,9 +1,9 @@
import { WorkerDeploymentStatus, WorkerInstanceGroupType } from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
import { type WorkerDeploymentStatus, type WorkerInstanceGroupType } from "@trigger.dev/database";
import { sqlDatabaseSchema, type PrismaClient, prisma } from "~/db.server";
import { type Organization } from "~/models/organization.server";
import { type Project } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
const pageSize = 20;
@@ -21,11 +21,13 @@ export class DeploymentListPresenter {
userId,
projectSlug,
organizationSlug,
environmentSlug,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: string;
page?: number;
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
@@ -63,17 +65,21 @@ export class DeploymentListPresenter {
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
const totalCount = await this.#prismaClient.workerDeployment.count({
where: {
projectId: project.id,
environmentId: environment.id,
},
});
const labeledDeployments = await this.#prismaClient.workerDeploymentPromotion.findMany({
where: {
environmentId: {
in: project.environments.map((env) => env.id),
},
environmentId: environment.id,
},
select: {
deploymentId: true,
@@ -118,6 +124,7 @@ INNER JOIN
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
WHERE
wd."projectId" = ${project.id}
AND wd."environmentId" = ${environment.id}
ORDER BY
string_to_array(wd."version", '.')::int[] DESC
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
@@ -126,11 +133,6 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
deployments: deployments.map((deployment, index) => {
const environment = project.environments.find((env) => env.id === deployment.environmentId);
if (!environment) {
throw new Error(`Environment not found for deployment ${deployment.id}`);
}
const label = labeledDeployments.find(
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
);
@@ -153,8 +155,6 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
id: environment.id,
type: environment.type,
slug: environment.slug,
userId: environment.orgMember?.user.id,
userName: getUsername(environment.orgMember?.user),
},
deployedBy: deployment.userId
? {
@@ -3,11 +3,12 @@ import {
ExternalBuildData,
prepareDeploymentError,
} from "@trigger.dev/core/v3";
import { WorkerDeployment } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { 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";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
export type ErrorData = {
@@ -28,11 +29,13 @@ export class DeploymentPresenter {
userId,
projectSlug,
organizationSlug,
environmentSlug,
deploymentShortCode,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: RuntimeEnvironment["slug"];
deploymentShortCode: WorkerDeployment["shortCode"];
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
@@ -53,10 +56,16 @@ export class DeploymentPresenter {
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
const deployment = await this.#prismaClient.workerDeployment.findFirstOrThrow({
where: {
projectId: project.id,
shortCode: deploymentShortCode,
environmentId: environment.id,
},
select: {
id: true,
@@ -5,7 +5,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { isCancellableRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
import { BasePresenter } from "./basePresenter.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { Direction } from "~/components/ListPagination";
import { type Direction } from "~/components/ListPagination";
export type RunListOptions = {
userId?: string;
@@ -65,7 +65,6 @@ export class RunListPresenter extends BasePresenter {
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
(bulkId !== undefined && bulkId !== "") ||
from !== undefined ||
@@ -1,87 +0,0 @@
import { PrismaClient, prisma } from "~/db.server";
import { getUsername } from "~/utils/username";
import { isFinalRunStatus } from "~/v3/taskStatus";
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
export type Run = Result["run"];
export class RunPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
runFriendlyId,
}: {
userId: string;
projectSlug: string;
organizationSlug: string;
runFriendlyId: string;
}) {
const run = await this.#prismaClient.taskRun.findFirstOrThrow({
select: {
id: true,
number: true,
traceId: true,
spanId: true,
friendlyId: true,
status: true,
completedAt: true,
logsDeletedAt: true,
runtimeEnvironment: {
select: {
id: true,
type: true,
slug: true,
organizationId: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
friendlyId: runFriendlyId,
project: {
slug: projectSlug,
},
},
});
return {
run: {
id: run.id,
number: run.number,
friendlyId: run.friendlyId,
traceId: run.traceId,
spanId: run.spanId,
status: run.status,
isFinished: isFinalRunStatus(run.status),
completedAt: run.completedAt,
logsDeletedAt: run.logsDeletedAt,
environment: {
id: run.runtimeEnvironment.id,
organizationId: run.runtimeEnvironment.organizationId,
type: run.runtimeEnvironment.type,
slug: run.runtimeEnvironment.slug,
userId: run.runtimeEnvironment.orgMember?.user.id,
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
},
},
};
}
}
@@ -5,6 +5,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getCurrentPlan, getLimit, getLimits } from "~/services/platform.v3.server";
import { calculateNextScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
import { BasePresenter } from "./basePresenter.server";
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
type ScheduleListOptions = {
projectId: string;
@@ -86,10 +87,9 @@ export class ScheduleListPresenter extends BasePresenter {
},
});
const schedulesCount = await this._prisma.taskSchedule.count({
where: {
projectId,
},
const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
prisma: this._replica,
environments: project.environments,
});
//get all possible scheduled tasks
@@ -1,25 +1,18 @@
import { Prisma } from "@trigger.dev/database";
import type {
RuntimeEnvironmentType,
TaskTriggerSource,
TaskRunStatus as TaskRunStatusType,
import {
Prisma,
type TaskTriggerSource,
type TaskRunStatus as TaskRunStatusType,
type RuntimeEnvironment,
type TaskRunStatus as DBTaskRunStatus,
} from "@trigger.dev/database";
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
import type { Organization } from "~/models/organization.server";
import type { Project } from "~/models/project.server";
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import type { User } from "~/models/user.server";
import {
filterOrphanedEnvironments,
onlyDevEnvironments,
exceptDevEnvironments,
sortEnvironments,
} from "~/utils/environmentSort";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { TaskRunStatus } from "~/database-types";
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
export type Task = {
@@ -28,12 +21,6 @@ export type Task = {
filePath: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
environments: {
id: string;
type: RuntimeEnvironmentType;
slug: string;
userName?: string;
}[];
};
type Return = Awaited<ReturnType<TaskListPresenter["call"]>>;
@@ -45,51 +32,36 @@ export class TaskListPresenter extends BasePresenter {
userId,
projectSlug,
organizationSlug,
environmentSlug,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: RuntimeEnvironment["slug"];
}) {
const project = await this._replica.project.findFirstOrThrow({
const environment = await this._replica.runtimeEnvironment.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
type: true,
projectId: true,
},
where: {
slug: projectSlug,
slug: environmentSlug,
project: {
slug: projectSlug,
},
organization: {
slug: organizationSlug,
},
},
});
const devEnvironments = onlyDevEnvironments(project.environments);
const nonDevEnvironments = exceptDevEnvironments(project.environments);
const tasks = await this._replica.$queryRaw<
{
id: string;
slug: string;
exportName: string;
filePath: string;
runtimeEnvironmentId: string;
createdAt: Date;
triggerSource: TaskTriggerSource;
}[]
@@ -99,15 +71,13 @@ export class TaskListPresenter extends BasePresenter {
FROM ${sqlDatabaseSchema}."WorkerDeploymentPromotion" wdp
INNER JOIN ${sqlDatabaseSchema}."WorkerDeployment" wd
ON wd.id = wdp."deploymentId"
WHERE wdp."environmentId" IN (${Prisma.join(nonDevEnvironments.map((e) => e.id))})
WHERE wdp."environmentId" = ${environment.id}
AND wdp."label" = ${CURRENT_DEPLOYMENT_LABEL}
),
workers AS (
workers AS (
SELECT DISTINCT ON ("runtimeEnvironmentId") id, "runtimeEnvironmentId", version
FROM ${sqlDatabaseSchema}."BackgroundWorker"
WHERE "runtimeEnvironmentId" IN (${Prisma.join(
filterOrphanedEnvironments(devEnvironments).map((e) => e.id)
)})
WHERE "runtimeEnvironmentId" = ${environment.id}
OR id IN (SELECT id FROM non_dev_workers)
ORDER BY "runtimeEnvironmentId", "createdAt" DESC
)
@@ -116,64 +86,29 @@ export class TaskListPresenter extends BasePresenter {
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" tasks ON tasks."workerId" = workers.id
ORDER BY slug ASC;`;
//group by the task identifier (task.slug).
const outputTasks = tasks.reduce((acc, task) => {
const environment = project.environments.find((env) => env.id === task.runtimeEnvironmentId);
if (!environment) {
throw new Error(`Environment not found for TaskRun ${task.id}`);
}
let existingTask = acc.find((t) => t.slug === task.slug);
if (!existingTask) {
existingTask = {
...task,
environments: [],
};
acc.push(existingTask);
}
//favour newer tasks
if (task.createdAt > existingTask.createdAt) {
existingTask.createdAt = task.createdAt;
existingTask.exportName = task.exportName;
existingTask.filePath = task.filePath;
existingTask.triggerSource = task.triggerSource;
}
existingTask.environments.push(displayableEnvironment(environment, userId));
//order the environments
existingTask.environments = sortEnvironments(existingTask.environments);
return acc;
}, [] as Task[]);
//then get the activity for each task
const activity = this.#getActivity(
outputTasks.map((t) => t.slug),
project.id
tasks.map((t) => t.slug),
environment.projectId,
environment.id
);
const runningStats = this.#getRunningStats(
outputTasks.map((t) => t.slug),
project.id
tasks.map((t) => t.slug),
environment.projectId,
environment.id
);
const durations = this.#getAverageDurations(
outputTasks.map((t) => t.slug),
project.id
tasks.map((t) => t.slug),
environment.projectId,
environment.id
);
const userEnvironment = project.environments.find((e) => e.orgMember?.user.id === userId);
const userHasTasks = userEnvironment
? outputTasks.some((t) => t.environments.some((e) => e.id === userEnvironment.id))
: false;
return { tasks: outputTasks, userHasTasks, activity, runningStats, durations };
return { tasks, environment, activity, runningStats, durations };
}
async #getActivity(tasks: string[], projectId: string) {
async #getActivity(tasks: string[], projectId: string, environmentId: string) {
if (tasks.length === 0) {
return {};
}
@@ -186,22 +121,23 @@ export class TaskListPresenter extends BasePresenter {
count: BigInt;
}[]
>`
SELECT
tr."taskIdentifier",
SELECT
tr."taskIdentifier",
tr."status",
DATE(tr."createdAt") as day,
COUNT(*)
FROM
DATE(tr."createdAt") as day,
COUNT(*)
FROM
${sqlDatabaseSchema}."TaskRun" as tr
WHERE
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."projectId" = ${projectId}
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."createdAt" >= (current_date - interval '6 days')
GROUP BY
tr."taskIdentifier",
tr."status",
GROUP BY
tr."taskIdentifier",
tr."status",
day
ORDER BY
ORDER BY
tr."taskIdentifier" ASC,
day ASC,
tr."status" ASC;`;
@@ -248,48 +184,68 @@ export class TaskListPresenter extends BasePresenter {
}, {} as Record<string, ({ day: string } & Record<TaskRunStatusType, number>)[]>);
}
async #getRunningStats(tasks: string[], projectId: string) {
async #getRunningStats(tasks: string[], projectId: string, environmentId: string) {
if (tasks.length === 0) {
return {};
}
const concurrencies = await concurrencyTracker.taskConcurrentRunCounts(projectId, tasks);
const queued = await this._replica.$queryRaw<
const stats = await this._replica.$queryRaw<
{
taskIdentifier: string;
status: DBTaskRunStatus;
count: BigInt;
}[]
>`
SELECT
SELECT
tr."taskIdentifier",
COUNT(*)
FROM
tr.status,
COUNT(*)
FROM
${sqlDatabaseSchema}."TaskRun" as tr
WHERE
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."projectId" = ${projectId}
AND tr."status" = ANY(ARRAY[${Prisma.join(QUEUED_STATUSES)}]::\"TaskRunStatus\"[])
GROUP BY
tr."taskIdentifier"
ORDER BY
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."status" = ANY(ARRAY[${Prisma.join([
...QUEUED_STATUSES,
"EXECUTING",
])}]::\"TaskRunStatus\"[])
GROUP BY
tr."taskIdentifier",
tr.status
ORDER BY
tr."taskIdentifier" ASC`;
//create an object combining the queued and concurrency counts
const result: Record<string, { queued: number; running: number }> = {};
for (const task of tasks) {
const concurrency = concurrencies[task] ?? 0;
const queuedCount = queued.find((q) => q.taskIdentifier === task)?.count ?? 0;
const queued = stats.filter(
(q) => q.taskIdentifier === task && QUEUED_STATUSES.includes(q.status)
);
const queuedCount =
queued.length === 0
? 0
: queued.reduce((acc, q) => {
return acc + Number(q.count);
}, 0);
const running = stats.filter((r) => r.taskIdentifier === task && r.status === "EXECUTING");
const runningCount =
running.length === 0
? 0
: running.reduce((acc, r) => {
return acc + Number(r.count);
}, 0);
result[task] = {
queued: Number(queuedCount),
running: concurrency,
queued: queuedCount,
running: runningCount,
};
}
return result;
}
async #getAverageDurations(tasks: string[], projectId: string) {
async #getAverageDurations(tasks: string[], projectId: string, environmentId: string) {
if (tasks.length === 0) {
return {};
}
@@ -299,18 +255,19 @@ export class TaskListPresenter extends BasePresenter {
taskIdentifier: string;
duration: Number;
}[]
>`
SELECT
tr."taskIdentifier",
>`
SELECT
tr."taskIdentifier",
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - COALESCE(tr."startedAt", tr."lockedAt")))) as duration
FROM
FROM
${sqlDatabaseSchema}."TaskRun" as tr
WHERE
WHERE
tr."taskIdentifier" IN (${Prisma.join(tasks)})
AND tr."projectId" = ${projectId}
AND tr."runtimeEnvironmentId" = ${environmentId}
AND tr."createdAt" >= (current_date - interval '6 days')
AND tr."status" IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS')
GROUP BY
GROUP BY
tr."taskIdentifier";`;
return Object.fromEntries(durations.map((s) => [s.taskIdentifier, Number(s.duration)]));
@@ -1,8 +1,7 @@
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
import { type TaskRunAttempt } from "@trigger.dev/database";
import { eventStream } from "remix-utils/sse/server";
import { PrismaClient, prisma } from "~/db.server";
import { type PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { eventRepository } from "~/v3/eventRepository.server";
import { projectPubSub } from "~/v3/services/projectPubSub.server";
type RunWithAttempts = {
@@ -26,11 +25,13 @@ export class TasksStreamPresenter {
request,
organizationSlug,
projectSlug,
environmentSlug,
userId,
}: {
request: Request;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
userId: string;
}) {
const project = await this.#prismaClient.project.findFirst({
@@ -1,92 +1,24 @@
import { TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
import { sortEnvironments } from "~/utils/environmentSort";
import { createSearchParams } from "~/utils/searchParams";
import { type RuntimeEnvironmentType, type TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema } from "~/db.server";
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
import { BasePresenter } from "./basePresenter.server";
type TaskListOptions = {
userId: string;
projectSlug: string;
url: string;
projectId: string;
environmentId: string;
environmentType: RuntimeEnvironmentType;
};
export type TaskList = Awaited<ReturnType<TestPresenter["call"]>>;
export type TaskListItem = NonNullable<TaskList["tasks"]>[0];
export type SelectedEnvironment = NonNullable<TaskList["selectedEnvironment"]>;
export class TestPresenter extends BasePresenter {
public async call({ userId, projectSlug, url }: TaskListOptions) {
// Find the project scoped to the organization
const project = await this._replica.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
},
where: {
OR: [
{
type: {
in: ["PREVIEW", "STAGING", "PRODUCTION"],
},
},
{
type: "DEVELOPMENT",
orgMember: {
userId,
},
},
],
},
},
},
where: {
slug: projectSlug,
},
});
const environments = sortEnvironments(
project.environments.map((environment) => ({
id: environment.id,
type: environment.type,
slug: environment.slug,
}))
);
const searchParams = createSearchParams(url, TestSearchParams);
//no environmentId
if (!searchParams.success) {
return {
hasSelectedEnvironment: false as const,
environments,
};
}
//default to dev environment
const environment = searchParams.params.get("environment") ?? "dev";
//is the environmentId valid?
const matchingEnvironment = project.environments.find((env) => env.slug === environment);
if (!matchingEnvironment) {
return {
hasSelectedEnvironment: false as const,
environments,
};
}
const isDev = matchingEnvironment.type === "DEVELOPMENT";
const tasks = await this.#getTasks(matchingEnvironment.id, isDev);
public async call({ userId, projectId, environmentId, environmentType }: TaskListOptions) {
const isDev = environmentType === "DEVELOPMENT";
const tasks = await this.#getTasks(environmentId, isDev);
return {
hasSelectedEnvironment: true as const,
environments,
selectedEnvironment: matchingEnvironment,
tasks: tasks.map((task) => {
return {
id: task.id,
@@ -1,17 +1,19 @@
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
import { BackgroundWorkerTask, RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database";
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
import { getTimezones } from "~/utils/timezones.server";
import { getUsername } from "~/utils/username";
import {
BackgroundWorkerTaskSlim,
type BackgroundWorkerTaskSlim,
findCurrentWorkerDeployment,
} from "~/v3/models/workerDeployment.server";
type TestTaskOptions = {
userId: string;
projectSlug: string;
environmentSlug: string;
projectId: string;
environment: {
id: string;
type: RuntimeEnvironmentType;
};
taskIdentifier: string;
};
@@ -21,12 +23,6 @@ type Task = {
filePath: string;
exportName: string;
friendlyId: string;
environment: {
id: string;
type: RuntimeEnvironmentType;
userId?: string;
userName?: string;
};
};
export type TestTask =
@@ -87,35 +83,10 @@ export class TestTaskPresenter {
public async call({
userId,
projectSlug,
environmentSlug,
projectId,
environment,
taskIdentifier,
}: TestTaskOptions): Promise<TestTaskResult> {
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
where: {
slug: environmentSlug,
project: {
slug: projectSlug,
},
orgMember: environmentSlug === "dev" ? { userId } : undefined,
},
select: {
id: true,
type: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
});
let task: BackgroundWorkerTaskSlim | null = null;
if (environment.type !== "DEVELOPMENT") {
const deployment = await findCurrentWorkerDeployment(environment.id);
@@ -182,12 +153,6 @@ export class TestTaskPresenter {
filePath: task.filePath,
exportName: task.exportName,
friendlyId: task.friendlyId,
environment: {
id: environment.id,
type: environment.type,
userId: environment.orgMember?.user.id,
userName: getUsername(environment.orgMember?.user),
},
};
switch (task.triggerSource) {
+8 -5
View File
@@ -1,12 +1,13 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import {
invitesPath,
newOrganizationPath,
newProjectPath,
v3EnvironmentPath,
v3ProjectPath,
} from "~/utils/pathBuilder";
@@ -20,11 +21,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return redirect(invitesPath());
}
const presenter = new SelectBestProjectPresenter();
const presenter = new SelectBestEnvironmentPresenter();
try {
const { project, organization } = await presenter.call({ userId: user.id, request });
const { project, organization, environment } = await presenter.call({
user,
});
//redirect them to the most appropriate project
return redirect(v3ProjectPath(organization, project));
return redirect(v3EnvironmentPath(organization, project, environment));
} catch (e) {
const organization = await prisma.organization.findFirst({
where: {
@@ -1,70 +1,53 @@
import { FolderIcon } from "@heroicons/react/20/solid";
import { Link, MetaFunction } from "@remix-run/react";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useOrganization } from "~/hooks/useOrganizations";
import { newProjectPath, v3ProjectPath } from "~/utils/pathBuilder";
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import {
newOrganizationPath,
newProjectPath,
OrganizationParamsSchema,
v3ProjectPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: "Projects | Trigger.dev",
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
projects: {
where: { deletedAt: null, version: "V3" },
select: {
id: true,
slug: true,
name: true,
updatedAt: true,
},
orderBy: { name: "asc" },
},
},
];
});
if (!org) {
throw redirect(newOrganizationPath());
}
const selector = new SelectBestEnvironmentPresenter();
const bestProject = await selector.selectBestProjectFromProjects({
user,
projectSlug: undefined,
projects: org.projects,
});
if (!bestProject) {
logger.info("Not Found: project", {
request,
project: bestProject,
});
throw redirect(newProjectPath({ slug: organizationSlug }));
}
return redirect(v3ProjectPath({ slug: organizationSlug }, bestProject));
};
export default function Page() {
const organization = useOrganization();
return (
<PageContainer>
<NavBar>
<PageTitle title={`${organization.title} projects`} />
<PageAccessories>
<Paragraph variant="extra-small" className="text-charcoal-500">
Org UID: {organization.id}
</Paragraph>
<LinkButton
to={newProjectPath(organization)}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new project
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{organization.projects.length > 0 ? (
organization.projects.map((project) => {
return (
<li key={project.id}>
<Link
className="border-grid-bright-dimmed flex gap-4 rounded-md border p-4 transition hover:bg-charcoal-900 "
to={v3ProjectPath(organization, project)}
>
<FolderIcon className="h-10 w-10 flex-none text-indigo-600" />
<div className="flex flex-col">
<Header3>{project.name}</Header3>
<Badge className="max-w-max">{project.version}</Badge>
</div>
</Link>
</li>
);
})
) : (
<li>
<LinkButton to={newProjectPath(organization)} variant="primary/small">
Create a Project
</LinkButton>
</li>
)}
</ul>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,43 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
deletedAt: null,
organization: { slug: organizationSlug, members: { some: { userId: user.id } } },
},
include: {
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const selector = new SelectBestEnvironmentPresenter();
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
return redirect(v3EnvironmentPath({ slug: organizationSlug }, project, environment));
};
@@ -1,7 +1,6 @@
import {
BeakerIcon,
BookOpenIcon,
ChatBubbleLeftRightIcon,
ChevronDownIcon,
ChevronUpIcon,
LightBulbIcon,
@@ -11,34 +10,31 @@ import {
} from "@heroicons/react/20/solid";
import { json, type MetaFunction } from "@remix-run/node";
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { DiscordIcon } from "@trigger.dev/companyicons";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { TaskRunStatus } from "@trigger.dev/database";
import { type TaskRunStatus } from "@trigger.dev/database";
import { Fragment, Suspense, useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { Feedback } from "~/components/Feedback";
import { HasNoTasksDeployed, HasNoTasksDev } from "~/components/BlankStatePanels";
import {
InitCommandV3,
PackageManagerProvider,
TriggerDevStepV3,
TriggerLoginStepV3,
} from "~/components/SetupCommands";
import { StepContentContainer } from "~/components/StepContentContainer";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -69,11 +65,16 @@ import {
taskTriggerSourceDescription,
TaskTriggerSourceIcon,
} from "~/components/runs/v3/TaskTriggerSource";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
import {
type Task,
type TaskActivity,
TaskListPresenter,
} from "~/presenters/v3/TaskListPresenter.server";
import {
getUsefulLinksPreference,
setUsefulLinksPreference,
@@ -83,8 +84,8 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
inviteTeamMemberPath,
ProjectParamSchema,
v3RunsPath,
v3TasksStreamingPath,
v3TestPath,
@@ -101,21 +102,22 @@ export const meta: MetaFunction = () => {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
try {
const presenter = new TaskListPresenter();
const { tasks, userHasTasks, activity, runningStats, durations } = await presenter.call({
const { tasks, environment, activity, runningStats, durations } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
});
const usefulLinksPreference = await getUsefulLinksPreference(request);
return typeddefer({
tasks,
userHasTasks,
environment,
activity,
runningStats,
durations,
@@ -149,7 +151,8 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
const organization = useOrganization();
const project = useProject();
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
const environment = useEnvironment();
const { tasks, activity, runningStats, durations, usefulLinksPreference } =
useTypedLoaderData<typeof loader>();
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
items: tasks,
@@ -180,7 +183,7 @@ export default function Page() {
//live reload the page when the tasks change
const revalidator = useRevalidator();
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project), {
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project, environment), {
event: "message",
});
@@ -211,13 +214,6 @@ export default function Page() {
{tasks.map((task) => (
<Property.Item key={task.slug}>
<Property.Label>{task.exportName}</Property.Label>
<Property.Value>
{task.environments
.map((e) =>
e.userName ? `${e.userName}/${e.id}` : `${e.type.slice(0, 3)}/${e.id}`
)
.join(", ")}
</Property.Value>
</Property.Item>
))}
</Property.Table>
@@ -237,7 +233,7 @@ export default function Page() {
<div className={cn("grid h-full grid-rows-1")}>
{hasTasks ? (
<div className="flex min-w-0 max-w-full flex-col">
{!userHasTasks && <UserHasNoTasks />}
{tasks.length === 0 ? <UserHasNoTasks /> : null}
<div className="max-h-full overflow-hidden">
<div className="flex items-center gap-1 p-2">
<Input
@@ -267,33 +263,19 @@ export default function Page() {
<TableHeaderCell>Queued</TableHeaderCell>
<TableHeaderCell>Activity (7d)</TableHeaderCell>
<TableHeaderCell>Avg. duration</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((task) => {
const path = v3RunsPath(organization, project, {
const path = v3RunsPath(organization, project, environment, {
tasks: [task.slug],
});
const devYouEnvironment = task.environments.find(
(e) => e.type === "DEVELOPMENT" && !e.userName
);
const firstDeployedEnvironment = task.environments
.filter((e) => e.type !== "DEVELOPMENT")
.at(0);
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
const testPath = testEnvironment
? v3TestTaskPath(
organization,
project,
{ taskIdentifier: task.slug },
testEnvironment.slug
)
: v3TestPath(organization, project);
const testPath = v3TestTaskPath(organization, project, environment, {
taskIdentifier: task.slug,
});
return (
<TableRow key={task.slug} className="group">
@@ -372,9 +354,6 @@ export default function Page() {
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={path}>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellMenu
isSticky
popoverContent={
@@ -417,9 +396,13 @@ export default function Page() {
</Table>
</div>
</div>
) : (
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-prose">
<CreateTaskInstructions />
<HasNoTasksDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<HasNoTasksDeployed environment={environment} />
</MainCenteredContainer>
)}
</div>
@@ -444,45 +427,6 @@ export default function Page() {
);
}
function CreateTaskInstructions() {
return (
<PackageManagerProvider>
<div>
<div className="mb-6 flex items-center justify-between border-b">
<Header1 spacing>Get setup in 3 minutes</Header1>
<div className="flex items-center gap-2">
<Feedback
button={
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
I'm stuck!
</Button>
}
defaultValue="help"
/>
</div>
</div>
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
<StepContentContainer>
<InitCommandV3 />
<Paragraph spacing>
You'll notice a new folder in your project called{" "}
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
in here to help you get started.
</Paragraph>
</StepContentContainer>
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
<StepContentContainer>
<TriggerDevStepV3 />
</StepContentContainer>
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
<StepContentContainer>
<Paragraph>This page will automatically refresh.</Paragraph>
</StepContentContainer>
</div>
</PackageManagerProvider>
);
}
function UserHasNoTasks() {
const [open, setOpen] = useState(false);
@@ -639,6 +583,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
return (
@@ -660,7 +605,7 @@ function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
</div>
<LinkWithIcon
variant="withIcon"
to={v3TestPath(organization, project)}
to={v3TestPath(organization, project, environment)}
description="Test your tasks"
icon={<BeakerIcon className="size-5 text-lime-500" />}
/>
@@ -1,14 +1,12 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import {
redirectBackWithSuccessMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { env } from "~/env.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { getUserSession } from "~/services/sessionStorage.server";
import {
EnvironmentParamSchema,
ProjectParamSchema,
v3NewProjectAlertPath,
v3NewProjectAlertPathConnectToSlackPath,
@@ -16,7 +14,7 @@ import {
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -34,7 +32,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
if (integration) {
return redirectWithSuccessMessage(
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
`${v3NewProjectAlertPath({ slug: organizationSlug }, project, {
slug: envParam,
})}?option=slack`,
request,
"Successfully connected your Slack workspace"
);
@@ -44,7 +44,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
"SLACK",
project.organizationId,
request,
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
slug: envParam,
})
);
}
}
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
@@ -24,6 +25,7 @@ import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -31,7 +33,11 @@ import { findProjectBySlug } from "~/models/project.server";
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
import {
EnvironmentParamSchema,
ProjectParamSchema,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
@@ -163,7 +169,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
@@ -197,7 +203,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Created ${alertChannel.name} alert`
);
@@ -211,6 +217,7 @@ export default function Page() {
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
@@ -251,7 +258,7 @@ export default function Page() {
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3ProjectAlertsPath(organization, project));
navigate(v3ProjectAlertsPath(organization, project, environment));
}
}}
>
@@ -407,24 +414,9 @@ export default function Page() {
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
</InputGroup>
<InputGroup>
<Label>Environments</Label>
<CheckboxWithLabel
name={environmentTypes.name}
id="PRODUCTION"
value="PRODUCTION"
variant="simple/small"
label="PROD"
defaultChecked
/>
<CheckboxWithLabel
name={environmentTypes.name}
id="STAGING"
value="STAGING"
variant="simple/small"
label="STAGING"
defaultChecked
/>
<Label>Environment</Label>
<input type="hidden" name={environmentTypes.name} value={environment.type} />
<EnvironmentCombo environment={environment} />
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
@@ -436,7 +428,7 @@ export default function Page() {
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project)}
to={v3ProjectAlertsPath(organization, project, environment)}
variant="tertiary/medium"
>
Cancel
@@ -10,15 +10,16 @@ import {
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { EnvironmentTypeLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
@@ -43,10 +44,12 @@ import {
} from "~/components/primitives/Tooltip";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
AlertChannelListPresenter,
type AlertChannelListPresenterRecord,
@@ -54,7 +57,7 @@ import {
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
EnvironmentParamSchema,
docsPath,
v3BillingPath,
v3NewProjectAlertPath,
@@ -71,10 +74,9 @@ export const meta: MetaFunction = () => {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
@@ -82,8 +84,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new AlertChannelListPresenter();
const data = await presenter.call(project.id);
const data = await presenter.call(project.id, environment.type);
return typedjson(data);
};
@@ -96,7 +106,7 @@ const schema = z.discriminatedUnion("action", [
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
@@ -123,7 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Deleted ${alertChannel.name} alert`
);
@@ -135,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Disabled ${alertChannel.name} alert`
);
@@ -147,7 +157,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Enabled ${alertChannel.name} alert`
);
@@ -157,8 +167,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const { alertChannels, limits } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const requiresUpgrade = limits.used >= limits.limit;
@@ -177,155 +188,166 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex h-fit items-end justify-between p-2 pl-3">
<Header2 className="">Project alerts</Header2>
{alertChannels.length > 0 && !requiresUpgrade && (
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert types</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.length > 0 ? (
alertChannels.map((alertChannel) => (
<TableRow key={alertChannel.id}>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.name}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell
className={cn("space-x-2", alertChannel.enabled ? "" : "opacity-50")}
>
{alertChannel.environmentTypes.map((environmentType) => (
<EnvironmentTypeLabel
key={environmentType}
environment={{ type: environmentType }}
/>
))}
</TableCell>
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus
enabled={alertChannel.enabled}
enabledIcon={BellAlertIcon}
disabledIcon={BellSlashIcon}
/>
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</>
}
className={
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
}
/>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6}>
<div className="flex flex-col items-center justify-center py-6">
<Header2 spacing className="text-text-bright">
You haven't created any project alerts yet
</Header2>
<Paragraph variant="small" className="mb-4">
Get alerted when runs or deployments fail, or when deployments succeed in
both Prod and Staging environments.
</Paragraph>
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
</TableCell>
</TableRow>
{alertChannels.length > 0 ? (
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex h-fit items-end justify-between p-2 pl-3">
<Header2 className="">Project alerts</Header2>
{alertChannels.length > 0 && !requiresUpgrade && (
<LinkButton
to={v3NewProjectAlertPath(organization, project, environment)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
)}
</TableBody>
</Table>
<div className="flex h-fit items-stretch gap-3">
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert types</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.length > 0 ? (
alertChannels.map((alertChannel) => (
<TableRow key={alertChannel.id}>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.name}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus
enabled={alertChannel.enabled}
enabledIcon={BellAlertIcon}
disabledIcon={BellSlashIcon}
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your available alerts. Upgrade your plan to
enable more.
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
)}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<div className="flex items-center gap-3">
{alertChannel.environmentTypes.map((environmentType) => (
<EnvironmentCombo
key={environmentType}
environment={{ type: environmentType }}
className="text-xs"
/>
))}
</div>
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</>
}
className={
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
}
/>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6}>
<div className="flex flex-col items-center justify-center py-6">
<Header2 spacing className="text-text-bright">
You haven't created any project alerts yet
</Header2>
<Paragraph variant="small" className="mb-4">
Get alerted when runs or deployments fail, or when deployments succeed in
both Prod and Staging environments.
</Paragraph>
<LinkButton
to={v3NewProjectAlertPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex h-fit items-stretch gap-3">
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your available alerts. Upgrade your plan
to enable more.
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
)}
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
</div>
</div>
</div>
</div>
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<AlertsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<AlertsNoneDeployed />
</MainCenteredContainer>
)}
<Outlet />
</PageBody>
</PageContainer>
@@ -1,10 +1,10 @@
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { MetaFunction } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { environmentTitle, EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -27,7 +27,7 @@ import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
@@ -99,7 +99,6 @@ export default function Page() {
<TableHeaderCell>Secret key</TableHeaderCell>
<TableHeaderCell>Key generated</TableHeaderCell>
<TableHeaderCell>Latest version</TableHeaderCell>
<TableHeaderCell>Env vars</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -107,7 +106,7 @@ export default function Page() {
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel environment={environment} />
<EnvironmentCombo environment={environment} />
</TableCell>
<TableCell>
<ClipboardField
@@ -121,7 +120,6 @@ export default function Page() {
<DateTime date={environment.updatedAt} />
</TableCell>
<TableCell>{environment.latestVersion ?? ""}</TableCell>
<TableCell>{environment.environmentVariableCount}</TableCell>
<TableCellMenu
isSticky
popoverContent={
@@ -133,6 +131,28 @@ export default function Page() {
></TableCellMenu>
</TableRow>
))}
{!hasStaging && (
<TableRow>
<TableCell>
<EnvironmentCombo environment={{ type: "STAGING" }} />
</TableCell>
<TableCell>
<LinkButton
to={v3BillingPath(
organization,
"Upgrade to unlock a Staging environment for your projects."
)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade to get staging environment
</LinkButton>
</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
)}
</TableBody>
</Table>
@@ -144,22 +164,6 @@ export default function Page() {
backend.
</Paragraph>
</InfoPanel>
{!hasStaging && (
<div className="flex items-center gap-2 pl-3 pr-2">
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
<Paragraph variant="small" className="text-text-bright">
Upgrade to add a Staging environment
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
</div>
)}
</div>
</div>
</PageBody>
@@ -4,14 +4,14 @@ import {
ExclamationCircleIcon,
} from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { MetaFunction, useLocation, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type MetaFunction, useLocation, useNavigation } 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";
import { BatchesNone } from "~/components/BlankStatePanels";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
@@ -38,17 +38,19 @@ import {
} 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";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
BatchList,
BatchListItem,
type BatchList,
type BatchListItem,
BatchListPresenter,
} from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
@@ -60,13 +62,23 @@ export const meta: MetaFunction = () => {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Error("Environment not found");
}
const url = new URL(request.url);
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
environments: url.searchParams.getAll("environments"),
environments: [environment.id],
statuses: url.searchParams.getAll("statuses"),
period: url.searchParams.get("period") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
@@ -75,12 +87,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
const filters = BatchListFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const presenter = new BatchListPresenter();
const list = await presenter.call({
userId,
@@ -94,7 +100,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export default function Page() {
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
const project = useProject();
return (
<PageContainer>
@@ -102,7 +107,6 @@ export default function Page() {
<PageTitle title="Batches" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
@@ -113,21 +117,27 @@ export default function Page() {
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<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 possibleEnvironments={project.environments} hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
{!hasFilters && batches.length === 0 ? (
<MainCenteredContainer className="max-w-md">
<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>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
/>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
/>
</div>
)}
</PageBody>
</PageContainer>
);
@@ -138,13 +148,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const isLoading = navigation.state !== "idle";
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
@@ -192,18 +202,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
</TableBlankRow>
) : (
batches.map((batch, index) => {
const path = v3BatchRunsPath(organization, project, batch);
const path = v3BatchRunsPath(organization, project, environment, batch);
return (
<TableRow key={batch.id}>
<TableCell to={path} isTabbableCell>
{batch.friendlyId}
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={batch.environment}
userName={batch.environment.userName}
/>
</TableCell>
<TableCell to={path}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
@@ -4,12 +4,12 @@ import {
ChatBubbleLeftEllipsisIcon,
} from "@heroicons/react/20/solid";
import { LockOpenIcon } from "@heroicons/react/24/solid";
import { Await, MetaFunction } from "@remix-run/react";
import { Await, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { typeddefer, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -95,7 +95,17 @@ export default function Page() {
</TableRow>
</TableHeader>
<TableBody>
<Suspense fallback={<Spinner />}>
<Suspense
fallback={
<TableRow>
<TableCell colSpan={4}>
<div className="grid place-items-center py-6">
<Spinner />
</div>
</TableCell>
</TableRow>
}
>
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
{(environments) => <EnvironmentsTable environments={environments} />}
</Await>
@@ -124,7 +134,7 @@ export default function Page() {
Upgrade for more concurrency
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
@@ -145,7 +155,7 @@ function EnvironmentsTable({ environments }: { environments: Environment[] }) {
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel environment={environment} userName={environment.userName} />
<EnvironmentCombo environment={environment} />
</TableCell>
<TableCell alignment="right">{environment.queued}</TableCell>
<TableCell alignment="right">{environment.concurrency}</TableCell>
@@ -1,10 +1,10 @@
import { Link, useLocation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
@@ -22,6 +22,7 @@ import {
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
@@ -33,7 +34,8 @@ import { capitalizeWord } from "~/utils/string";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, deploymentParam } = v3DeploymentParams.parse(params);
const { organizationSlug, projectParam, envParam, deploymentParam } =
v3DeploymentParams.parse(params);
try {
const presenter = new DeploymentPresenter();
@@ -41,6 +43,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
deploymentShortCode: deploymentParam,
});
@@ -55,16 +58,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export default function Page() {
const { deployment } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
const user = useUser();
const { deployment } = useTypedLoaderData<typeof loader>();
const page = new URLSearchParams(location.search).get("page");
const usernameForEnv =
user.id !== deployment.environment.userId ? deployment.environment.userName : undefined;
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">
@@ -107,7 +108,9 @@ export default function Page() {
</AdminDebugTooltip>
<LinkButton
to={`${v3DeploymentsPath(organization, project)}${page ? `?page=${page}` : ""}`}
to={`${v3DeploymentsPath(organization, project, environment)}${
page ? `?page=${page}` : ""
}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
@@ -129,10 +132,7 @@ export default function Page() {
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
<EnvironmentCombo environment={deployment.environment} />
</Property.Value>
</Property.Item>
<Property.Item>
@@ -1,23 +1,17 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
BookOpenIcon,
ServerStackIcon,
} from "@heroicons/react/20/solid";
import { MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { WorkerInstanceGroupType } from "@trigger.dev/database";
import { ArrowPathIcon, ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
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 { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -36,7 +30,6 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import {
DeploymentStatus,
deploymentStatusDescription,
@@ -47,20 +40,15 @@ import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import {
DeploymentListItem,
type DeploymentListItem,
DeploymentListPresenter,
} from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
ProjectParamSchema,
docsPath,
v3DeploymentPath,
v3EnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
@@ -79,7 +67,7 @@ const SearchParams = z.object({
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const searchParams = createSearchParams(request.url, SearchParams);
const page = searchParams.success ? searchParams.params.get("page") ?? 1 : 1;
@@ -90,6 +78,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
page,
});
@@ -106,7 +95,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
export default function Page() {
const organization = useOrganization();
const project = useProject();
const user = useUser();
const environment = useEnvironment();
const { deployments, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
@@ -137,7 +126,6 @@ export default function Page() {
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell
tooltip={
@@ -172,13 +160,10 @@ export default function Page() {
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(
organization,
project,
environment,
deployment,
currentPage
);
@@ -193,12 +178,6 @@ export default function Page() {
)}
</div>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
{deployment.version}
</TableCell>
@@ -248,7 +227,7 @@ export default function Page() {
);
})
) : (
<TableBlankRow colSpan={8}>
<TableBlankRow colSpan={7}>
<Paragraph className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
@@ -262,8 +241,14 @@ export default function Page() {
</div>
)}
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<CreateDeploymentInstructions />
<MainCenteredContainer className="max-w-md">
<DeploymentsNone />
</MainCenteredContainer>
)}
</ResizablePanel>
@@ -281,50 +266,6 @@ export default function Page() {
);
}
function CreateDeploymentInstructions() {
const organization = useOrganization();
const project = useProject();
return (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={ServerStackIcon}
iconClassName="text-blue-500"
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, Continuous Integration
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
you{" "}
<TextLink href={v3EnvironmentVariablesPath(organization, project)}>
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>
</MainCenteredContainer>
);
}
function DeploymentActionsCell({
deployment,
path,
@@ -386,7 +327,7 @@ function DeploymentActionsCell({
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnRightIcon}
LeadingIcon={PromoteIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
@@ -16,6 +16,8 @@ import { z } from "zod";
import {
environmentTextClassName,
environmentTitle,
EnvironmentCombo,
EnvironmentLabel,
} from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
@@ -35,7 +37,12 @@ import { useProject } from "~/hooks/useProject";
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3BillingPath, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
import {
EnvironmentParamSchema,
ProjectParamSchema,
v3BillingPath,
v3EnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
import dotenv from "dotenv";
@@ -47,6 +54,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -108,7 +116,7 @@ const schema = z.object({
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
@@ -160,7 +168,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return json(submission);
}
return redirect(v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }));
return redirect(
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
)
);
};
export default function Page() {
@@ -171,6 +185,7 @@ export default function Page() {
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
@@ -195,7 +210,7 @@ export default function Page() {
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3EnvironmentVariablesPath(organization, project));
navigate(v3EnvironmentVariablesPath(organization, project, environment));
}
}}
>
@@ -217,16 +232,7 @@ export default function Page() {
value={environment.id}
name="environmentIds"
type="radio"
label={
<span
className={cn(
"text-xs uppercase tracking-wide",
environmentTextClassName(environment)
)}
>
{environmentTitle(environment)}
</span>
}
label={<EnvironmentLabel environment={environment} className="text-sm" />}
variant="button"
/>
))}
@@ -239,9 +245,7 @@ export default function Page() {
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-3 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
>
<LockClosedIcon className="size-4 text-charcoal-500" />
<Paragraph className="mt-0.5 text-xs uppercase tracking-wide text-staging">
Staging
</Paragraph>
<EnvironmentLabel environment={{ type: "STAGING" }} className="text-sm" />
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
@@ -305,7 +309,7 @@ export default function Page() {
}
cancelButton={
<LinkButton
to={v3EnvironmentVariablesPath(organization, project)}
to={v3EnvironmentVariablesPath(organization, project, environment)}
variant="tertiary/medium"
>
Cancel
@@ -3,25 +3,23 @@ import { parse } from "@conform-to/zod";
import {
BookOpenIcon,
InformationCircleIcon,
LockOpenIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import {
ActionFunctionArgs,
LoaderFunctionArgs,
type ActionFunctionArgs,
type LoaderFunctionArgs,
json,
redirectDocument,
} from "@remix-run/server-runtime";
import { RuntimeEnvironment } from "@trigger.dev/database";
import { type RuntimeEnvironment } from "@trigger.dev/database";
import { Fragment, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
@@ -35,7 +33,6 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
@@ -47,19 +44,20 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import {
EnvironmentVariableWithSetValues,
type EnvironmentVariableWithSetValues,
EnvironmentVariablesPresenter,
} from "~/presenters/v3/EnvironmentVariablesPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
ProjectParamSchema,
docsPath,
v3BillingPath,
v3EnvironmentVariablesPath,
v3NewEnvironmentVariablesPath,
} from "~/utils/pathBuilder";
@@ -109,7 +107,7 @@ const schema = z.discriminatedUnion("action", [
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
@@ -154,7 +152,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
//use redirectDocument because it reloads the page
return redirectDocument(
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
),
{
headers: {
refresh: "true",
@@ -172,7 +174,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
return redirectWithSuccessMessage(
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
),
request,
`Deleted ${submission.value.key} environment variable`
);
@@ -183,8 +189,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, environments, hasStaging } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<PageContainer>
@@ -211,7 +218,7 @@ export default function Page() {
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
@@ -225,8 +232,8 @@ export default function Page() {
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
{environments.map((environment) => (
<TableHeaderCell key={environment.id}>
<EnvironmentLabel environment={environment} />
<TableHeaderCell key={environment.id} className="pl-1">
<EnvironmentCombo environment={environment} className="text-sm" />
</TableHeaderCell>
))}
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
@@ -275,7 +282,7 @@ export default function Page() {
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
@@ -289,26 +296,11 @@ export default function Page() {
</TableBody>
</Table>
<div className="z-10 -mt-px flex w-full flex-wrap justify-between border-t border-grid-dimmed">
<div className="-mt-px w-full border-t border-grid-dimmed">
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
Dev environment variables specified here will be overridden by ones in your .env file
when running locally.
</InfoPanel>
{!hasStaging && (
<div className="flex items-center gap-2 pl-3 pr-2">
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
<Paragraph variant="small" className="text-text-bright">
Upgrade to add a Staging environment
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
>
Upgrade
</LinkButton>
</div>
)}
</div>
</div>
<Outlet />
@@ -409,7 +401,7 @@ function EditEnvironmentVariablePanel({
className="flex items-center justify-end"
htmlFor={`values[${index}].value`}
>
<EnvironmentLabel environment={environment} size="large" className="px-2" />
<EnvironmentCombo environment={environment} className="text-sm" />
</label>
<Input
name={`values[${index}].value`}
@@ -12,14 +12,14 @@ import {
StopCircleIcon,
} from "@heroicons/react/20/solid";
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
import { LoaderFunctionArgs, SerializeFrom, json } from "@remix-run/server-runtime";
import { Virtualizer } from "@tanstack/react-virtual";
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
import { type Virtualizer } from "@tanstack/react-virtual";
import {
formatDurationMilliseconds,
millisecondsToNanoseconds,
nanosecondsToMilliseconds,
} from "@trigger.dev/core/v3";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
@@ -27,7 +27,7 @@ import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParen
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -51,8 +51,12 @@ import { Slider } from "~/components/primitives/Slider";
import { Switch } from "~/components/primitives/Switch";
import * as Timeline from "~/components/primitives/Timeline";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TreeView, UseTreeStateOutput, useTree } from "~/components/primitives/TreeView/TreeView";
import { NodesState } from "~/components/primitives/TreeView/reducer";
import {
TreeView,
type UseTreeStateOutput,
useTree,
} from "~/components/primitives/TreeView/TreeView";
import { type NodesState } from "~/components/primitives/TreeView/reducer";
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
import { RunIcon } from "~/components/runs/v3/RunIcon";
@@ -64,14 +68,15 @@ import {
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";
import { useInitialDimensions } from "~/hooks/useInitialDimensions";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
import { Run, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess } from "~/hooks/useUser";
import { RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { getResizableSnapshot } from "~/services/resizablePanel.server";
import { requireUserId } from "~/services/session.server";
@@ -87,7 +92,10 @@ import {
v3RunsPath,
} from "~/utils/pathBuilder";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
import { useDevPresence } from "~/components/DevPresence";
import { DisconnectedIcon } from "~/assets/icons/ConnectionIcons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
const resizableSettings = {
parent: {
@@ -124,7 +132,7 @@ type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params);
const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params);
const presenter = new RunPresenter();
const result = await presenter.call({
@@ -154,30 +162,19 @@ type LoaderData = SerializeFrom<typeof loader>;
export default function Page() {
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
const user = useUser();
const organization = useOrganization();
const project = useProject();
const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined;
const environment = useEnvironment();
return (
<>
<NavBar>
<PageTitle
backButton={{
to: v3RunsPath(organization, project),
to: v3RunsPath(organization, project, environment),
text: "Runs",
}}
title={
<div className="flex items-center gap-3">
<span>Run #{run.number}</span>
<EnvironmentLabel
size="large"
environment={run.environment}
userName={usernameForEnv}
/>
</div>
}
title={`Run #${run.number}`}
/>
<PageAccessories>
<AdminDebugTooltip>
@@ -218,6 +215,7 @@ export default function Page() {
failedRedirect={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
@@ -235,6 +233,7 @@ export default function Page() {
redirectPath={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
@@ -267,6 +266,7 @@ export default function Page() {
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { searchParams, replaceSearchParam } = useReplaceSearchParams();
const selectedSpanId = searchParams.get("span") ?? undefined;
@@ -282,10 +282,13 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
}, 250);
const revalidator = useRevalidator();
const streamedEvents = useEventSource(v3RunStreamingPath(organization, project, run), {
event: "message",
disabled: !shouldLiveReload,
});
const streamedEvents = useEventSource(
v3RunStreamingPath(organization, project, environment, run),
{
event: "message",
disabled: !shouldLiveReload,
}
);
useEffect(() => {
if (streamedEvents !== null) {
revalidator.revalidate();
@@ -325,6 +328,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
shouldLiveReload={shouldLiveReload}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
isCompleted={run.completedAt !== null}
/>
</ResizablePanel>
<ResizableHandle id={resizableSettings.parent.handleId} />
@@ -452,6 +456,7 @@ type TasksTreeViewProps = {
taskIdentifier: string;
spanId: string;
} | null;
isCompleted: boolean;
};
function TasksTreeView({
@@ -466,6 +471,7 @@ function TasksTreeView({
shouldLiveReload,
maximumLiveReloadingSetting,
rootRun,
isCompleted,
}: TasksTreeViewProps) {
const isAdmin = useHasAdminAccess();
const [filterText, setFilterText] = useState("");
@@ -477,6 +483,8 @@ function TasksTreeView({
const treeScrollRef = useRef<HTMLDivElement>(null);
const timelineScrollRef = useRef<HTMLDivElement>(null);
const displayEvents = showDebug ? events : events.filter((event) => !event.data.isDebug);
const {
nodes,
getTreeProps,
@@ -490,7 +498,7 @@ function TasksTreeView({
scrollToNode,
virtualizer,
} = useTree({
tree: showDebug ? events : events.filter((event) => !event.data.isDebug),
tree: displayEvents,
selectedId,
// collapsedIds,
onSelectedIdChanged,
@@ -569,7 +577,7 @@ function TasksTreeView({
nodes={nodes}
getNodeProps={getNodeProps}
getTreeProps={getTreeProps}
renderNode={({ node, state }) => (
renderNode={({ node, state, index }) => (
<>
<div
className={cn(
@@ -636,9 +644,9 @@ function TasksTreeView({
</div>
</div>
</div>
{events.length === 1 && environmentType === "DEVELOPMENT" && (
<ConnectedDevWarning />
)}
{!isCompleted &&
environmentType === "DEVELOPMENT" &&
index === displayEvents.length - 1 && <ConnectedDevWarning />}
</>
)}
onScroll={(scrollTop) => {
@@ -1049,6 +1057,7 @@ function ShowParentLink({
const [mouseOver, setMouseOver] = useState(false);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { spanParam } = useParams();
const span = spanId ? spanId : spanParam;
@@ -1061,12 +1070,13 @@ function ShowParentLink({
? v3RunSpanPath(
organization,
project,
environment,
{
friendlyId: runFriendlyId,
},
{ spanId: span }
)
: v3RunPath(organization, project, {
: v3RunPath(organization, project, environment, {
friendlyId: runFriendlyId,
})
}
@@ -1239,30 +1249,25 @@ function CurrentTimeIndicator({
}
function ConnectedDevWarning() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 3000);
return () => clearTimeout(timer);
}, []);
const { isConnected } = useDevPresence();
return (
<div
className={cn(
"flex items-center overflow-hidden pl-5 pr-2 transition-opacity duration-500",
isVisible ? "opacity-100" : "h-0 opacity-0"
isConnected ? "h-0 opacity-0" : "opacity-100"
)}
>
<Callout variant="info" className="mt-2">
<Callout
variant="error"
icon={<DisconnectedIcon className="size-5 shrink-0" />}
className="mt-2"
>
<div className="flex flex-col gap-1">
<Paragraph variant="small">
Runs usually start within 1 second in{" "}
<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />. Check you're running the
CLI: <InlineCode className="whitespace-nowrap">npx trigger.dev@latest dev</InlineCode>
<Paragraph variant="small" spacing>
Your local dev server is not connectedr. Check you're running the CLI:
</Paragraph>
<ClipboardField variant="secondary/small" value="npx trigger.dev@latest dev" />
</div>
</Callout>
</div>
@@ -1,7 +1,7 @@
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
import { Form, MetaFunction, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Form, type MetaFunction, useNavigation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { IconCircleX } from "@tabler/icons-react";
import { AnimatePresence, motion } from "framer-motion";
import { ListChecks, ListX } from "lucide-react";
@@ -46,12 +46,16 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
ProjectParamSchema,
v3ProjectPath,
v3RunsPath,
v3TestPath,
} from "~/utils/pathBuilder";
import { ListPagination } from "../../components/ListPagination";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
export const meta: MetaFunction = () => {
return [
@@ -63,7 +67,7 @@ export const meta: MetaFunction = () => {
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
@@ -74,11 +78,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
rootOnlyValue = await getRootOnlyFilterPreference(request);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Error("Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Error("Environment not found");
}
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
statuses: url.searchParams.getAll("statuses"),
environments: url.searchParams.getAll("environments"),
environments: [environment.id],
tasks: url.searchParams.getAll("tasks"),
period: url.searchParams.get("period") ?? undefined,
bulkId: url.searchParams.get("bulkId") ?? undefined,
@@ -108,12 +122,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
scheduleId,
} = TaskRunListSearchFilters.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Error("Project not found");
}
const presenter = new RunListPresenter();
const list = presenter.call({
userId,
@@ -155,7 +163,6 @@ export default function Page() {
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const project = useProject();
return (
<>
@@ -210,7 +217,6 @@ export default function Page() {
>
<div className="flex items-start justify-between gap-x-2 p-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
@@ -313,7 +319,8 @@ function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
const organization = useOrganization();
const project = useProject();
const failedRedirect = v3RunsPath(organization, project);
const environment = useEnvironment();
const failedRedirect = v3RunsPath(organization, project, environment);
const formAction = `/resources/taskruns/bulk/cancel`;
@@ -336,16 +343,15 @@ function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
<DialogContent key="replay">
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
<DialogDescription className="pt-2">
<Paragraph>
Canceling these runs will stop them from running. Only runs that are not already
finished will be canceled, the others will remain in their existing state.
</Paragraph>
Canceling these runs will stop them from running. Only runs that are not already finished
will be canceled, the others will remain in their existing state.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
@@ -370,7 +376,8 @@ function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
const organization = useOrganization();
const project = useProject();
const failedRedirect = v3RunsPath(organization, project);
const environment = useEnvironment();
const failedRedirect = v3RunsPath(organization, project, environment);
const formAction = `/resources/taskruns/bulk/replay`;
@@ -393,16 +400,15 @@ function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
<DialogContent key="replay">
<DialogHeader>Replay runs?</DialogHeader>
<DialogDescription className="pt-2">
<Paragraph>
Replaying these runs will create a new run for each with the same payload and
environment as the original. It will use the latest version of the code for each task.
</Paragraph>
Replaying these runs will create a new run for each with the same payload and environment
as the original. It will use the latest version of the code for each task.
</DialogDescription>
<DialogFooter>
<Form action={formAction} method="post" reloadDocument>
<input type="hidden" name="failedRedirect" value={failedRedirect} />
<input type="hidden" name="organizationSlug" value={organization.slug} />
<input type="hidden" name="projectSlug" value={project.slug} />
<input type="hidden" name="environmentSlug" value={environment.slug} />
{[...selectedItems].map((runId) => (
<input key={runId} type="hidden" name="runIds" value={runId} />
))}
@@ -448,6 +454,7 @@ function CreateFirstTaskInstructions() {
function RunTaskInstructions() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<MainCenteredContainer className="max-w-prose">
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
@@ -458,7 +465,7 @@ function RunTaskInstructions() {
page.
</Paragraph>
<LinkButton
to={v3TestPath(organization, project)}
to={v3TestPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={BeakerIcon}
className="inline-flex"
@@ -13,7 +13,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
@@ -40,6 +40,7 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -95,7 +96,8 @@ const schema = z.discriminatedUnion("action", [
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, scheduleParam } = v3ScheduleParams.parse(params);
const { organizationSlug, projectParam, envParam, scheduleParam } =
v3ScheduleParams.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema });
@@ -115,6 +117,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
@@ -132,7 +135,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
friendlyId: scheduleParam,
});
return redirectWithSuccessMessage(
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`${scheduleParam} deleted`
);
@@ -141,6 +144,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
@@ -165,6 +169,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
@@ -175,6 +180,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
@@ -200,6 +206,7 @@ export default function Page() {
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const isUtc = schedule.timezone === "UTC";
@@ -215,7 +222,7 @@ export default function Page() {
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
<LinkButton
to={`${v3SchedulesPath(organization, project)}${location.search}`}
to={`${v3SchedulesPath(organization, project, environment)}${location.search}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
@@ -255,9 +262,13 @@ export default function Page() {
<Property.Value>{schedule.timezone}</Property.Value>
</Property.Item>
<Property.Item className="gap-1">
<Property.Label>Environments</Property.Label>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentLabels size="small" environments={schedule.environments} />
<div className="flex flex-col gap-2">
{schedule.environments.map((env) => (
<EnvironmentCombo key={env.id} environment={env} className="text-xs" />
))}
</div>
</Property.Value>
</Property.Item>
{isImperative && (
@@ -408,7 +419,9 @@ export default function Page() {
<div className="flex items-center gap-4">
<LinkButton
variant="tertiary/medium"
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
location.search
}`}
LeadingIcon={PencilSquareIcon}
>
Edit schedule

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