diff --git a/apps/proxy/.dev.vars.example b/apps/proxy/.dev.vars.example deleted file mode 100644 index 76de44a191..0000000000 --- a/apps/proxy/.dev.vars.example +++ /dev/null @@ -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= \ No newline at end of file diff --git a/apps/proxy/.editorconfig b/apps/proxy/.editorconfig deleted file mode 100644 index 64ab2601f9..0000000000 --- a/apps/proxy/.editorconfig +++ /dev/null @@ -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 diff --git a/apps/proxy/.gitignore b/apps/proxy/.gitignore deleted file mode 100644 index 3b0fe33c47..0000000000 --- a/apps/proxy/.gitignore +++ /dev/null @@ -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/ diff --git a/apps/proxy/.prettierrc b/apps/proxy/.prettierrc deleted file mode 100644 index 89c93d85a8..0000000000 --- a/apps/proxy/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "jsxSingleQuote": false, - "trailingComma": "es5", - "bracketSpacing": true, - "bracketSameLine": false, - "printWidth": 100, - "tabWidth": 2, - "useTabs": false -} diff --git a/apps/proxy/CHANGELOG.md b/apps/proxy/CHANGELOG.md deleted file mode 100644 index 6544c5fee1..0000000000 --- a/apps/proxy/CHANGELOG.md +++ /dev/null @@ -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 diff --git a/apps/proxy/README.md b/apps/proxy/README.md deleted file mode 100644 index f3010f2af7..0000000000 --- a/apps/proxy/README.md +++ /dev/null @@ -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 "/*" --env staging -``` - -Prod: - -```bash -npx wrangler@latest deploy --route "/*" --env prod -``` - -## Development - -Set the environment variables as described above. - -1. `pnpm install` -2. `pnpm run dev --filter proxy` diff --git a/apps/proxy/package.json b/apps/proxy/package.json deleted file mode 100644 index 80646e60a0..0000000000 --- a/apps/proxy/package.json +++ /dev/null @@ -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" - } -} \ No newline at end of file diff --git a/apps/proxy/src/apikey.ts b/apps/proxy/src/apikey.ts deleted file mode 100644 index cb6c9c2344..0000000000 --- a/apps/proxy/src/apikey.ts +++ /dev/null @@ -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_"); -} diff --git a/apps/proxy/src/events/queueEvent.ts b/apps/proxy/src/events/queueEvent.ts deleted file mode 100644 index d3b2dcce54..0000000000 --- a/apps/proxy/src/events/queueEvent.ts +++ /dev/null @@ -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 { - //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, - } - ); - } -} diff --git a/apps/proxy/src/events/queueEvents.ts b/apps/proxy/src/events/queueEvents.ts deleted file mode 100644 index 412db29acd..0000000000 --- a/apps/proxy/src/events/queueEvents.ts +++ /dev/null @@ -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 { - //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, - } - ); - } -} diff --git a/apps/proxy/src/events/utils.ts b/apps/proxy/src/events/utils.ts deleted file mode 100644 index e68643d88e..0000000000 --- a/apps/proxy/src/events/utils.ts +++ /dev/null @@ -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; -} diff --git a/apps/proxy/src/index.ts b/apps/proxy/src/index.ts deleted file mode 100644 index 26d7d3b00d..0000000000 --- a/apps/proxy/src/index.ts +++ /dev/null @@ -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 { - 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 - ); -} diff --git a/apps/proxy/src/json.ts b/apps/proxy/src/json.ts deleted file mode 100644 index c8c2aca7bf..0000000000 --- a/apps/proxy/src/json.ts +++ /dev/null @@ -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); -} diff --git a/apps/proxy/src/rateLimit.ts b/apps/proxy/src/rateLimit.ts deleted file mode 100644 index ccbd7b4338..0000000000 --- a/apps/proxy/src/rateLimit.ts +++ /dev/null @@ -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 -): Promise { - 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(); -} diff --git a/apps/proxy/src/rateLimiter.ts b/apps/proxy/src/rateLimiter.ts deleted file mode 100644 index 4143323431..0000000000 --- a/apps/proxy/src/rateLimiter.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface Ratelimit { - /* - * The ratelimit function - * @param {RatelimitOptions} options - * @returns {Promise} - */ - limit: (options: RatelimitOptions) => Promise; -} - -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; -} diff --git a/apps/proxy/tsconfig.json b/apps/proxy/tsconfig.json deleted file mode 100644 index b35efe3073..0000000000 --- a/apps/proxy/tsconfig.json +++ /dev/null @@ -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/*"] - } - } -} diff --git a/apps/proxy/wrangler.toml b/apps/proxy/wrangler.toml deleted file mode 100644 index 3cbfb66cd8..0000000000 --- a/apps/proxy/wrangler.toml +++ /dev/null @@ -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 } \ No newline at end of file diff --git a/apps/webapp/app/assets/icons/ConnectionIcons.tsx b/apps/webapp/app/assets/icons/ConnectionIcons.tsx new file mode 100644 index 0000000000..74f1ee5458 --- /dev/null +++ b/apps/webapp/app/assets/icons/ConnectionIcons.tsx @@ -0,0 +1,53 @@ +export function ConnectedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DisconnectedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/DropdownIcon.tsx b/apps/webapp/app/assets/icons/DropdownIcon.tsx new file mode 100644 index 0000000000..4a869ec8f6 --- /dev/null +++ b/apps/webapp/app/assets/icons/DropdownIcon.tsx @@ -0,0 +1,20 @@ +export function DropdownIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx new file mode 100644 index 0000000000..d8d8a7b66b --- /dev/null +++ b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx @@ -0,0 +1,92 @@ +export function DevEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ); +} + +export function ProdEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} + +export function DeployedEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PromoteIcon.tsx b/apps/webapp/app/assets/icons/PromoteIcon.tsx new file mode 100644 index 0000000000..be70388877 --- /dev/null +++ b/apps/webapp/app/assets/icons/PromoteIcon.tsx @@ -0,0 +1,24 @@ +export function PromoteIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx b/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx new file mode 100644 index 0000000000..7bcb261c4d --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx @@ -0,0 +1,10 @@ +export function ToggleArrowIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/images/cli-connected.png b/apps/webapp/app/assets/images/cli-connected.png new file mode 100644 index 0000000000..cd6b4e37fe Binary files /dev/null and b/apps/webapp/app/assets/images/cli-connected.png differ diff --git a/apps/webapp/app/assets/images/cli-disconnected.png b/apps/webapp/app/assets/images/cli-disconnected.png new file mode 100644 index 0000000000..dff3ecc106 Binary files /dev/null and b/apps/webapp/app/assets/images/cli-disconnected.png differ diff --git a/apps/webapp/app/assets/images/color-wheel.png b/apps/webapp/app/assets/images/color-wheel.png new file mode 100644 index 0000000000..af76136e82 Binary files /dev/null and b/apps/webapp/app/assets/images/color-wheel.png differ diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx new file mode 100644 index 0000000000..b0b49fa2cb --- /dev/null +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -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 ( + +
+
+ Get setup in 3 minutes +
+ + I'm stuck! + + } + defaultValue="help" + /> +
+
+ + + + + You'll notice a new folder in your project called{" "} + trigger. We've added a very simple example task + in here to help you get started. + + + + + + + + + This page will automatically refresh. + +
+
+ ); +} + +export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) { + return ( + + + You don't have any deployed tasks in {environmentFullTitle(environment)}. + + + How to deploy tasks + + + ); +} + +export function SchedulesNoPossibleTaskPanel() { + return ( + + + You have no scheduled tasks in your project. Before you can schedule a task you need to + create a schedules.task. + + + View the docs + + + ); +} + +export function SchedulesNoneAttached() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const location = useLocation(); + + return ( + + + Scheduled tasks will only run automatically if you connect a schedule to them, you can do + this in the dashboard or using the SDK. + +
+ + Use the dashboard + + + Use the SDK + +
+
+ ); +} + +export function BatchesNone() { + return ( + + + You have no batches in this environment. You can trigger batches from your backend or from + inside other tasks. + + + How to trigger batches + + + ); +} + +export function TestHasNoTasks() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + + You have no tasks in this environment. + + + Add tasks + + + ); +} + +export function DeploymentsNone() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + + 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{" "} + + set your environment variables + {" "} + first. + +
+ + Deploy with the CLI + + + Deploy with GitHub actions + +
+
+ ); +} + +export function DeploymentsNoneDev() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + + This is the Development environment. When you're ready to deploy your tasks, switch to a + different environment. + + + 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{" "} + + set your environment variables + {" "} + first. + +
+ + Deploy with the CLI + + + Deploy with GitHub actions + +
+
+ +
+ ); +} + +export function AlertsNoneDev() { + return ( +
+ + + You can get alerted when deployed runs fail. + + + We don't support alerts in the Development environment. Switch to a deployed environment + to setup alerts. + +
+ + How to setup alerts + +
+
+ +
+ ); +} + +export function AlertsNoneDeployed() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + + You can get alerted when deployed runs fail. We currently support sending Slack, Email, + and webhooks. + + +
+ + New alert + + + Alert docs + +
+
+
+ ); +} + +function AlertsNoneProd() { + return ( +
+ + + You can get alerted when deployed runs fail. + + + We don't support alerts in the Development environment. Switch to a deployed environment + to setup alerts. + +
+ + How to setup alerts + +
+
+ +
+ ); +} + +function SwitcherPanel() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + Switch to a deployed environment + + +
+ ); +} diff --git a/apps/webapp/app/components/DevPresence.tsx b/apps/webapp/app/components/DevPresence.tsx new file mode 100644 index 0000000000..bd69bbe21e --- /dev/null +++ b/apps/webapp/app/components/DevPresence.tsx @@ -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({ + 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(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 {children}; +} + +// 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; +} diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index c6544f6496..1a8f4b2ad9 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -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?: { diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index 47e94c3ba3..fc376480a6 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -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]; diff --git a/apps/webapp/app/components/admin/debugTooltip.tsx b/apps/webapp/app/components/admin/debugTooltip.tsx index f761e23fa9..2dfcce9634 100644 --- a/apps/webapp/app/components/admin/debugTooltip.tsx +++ b/apps/webapp/app/components/admin/debugTooltip.tsx @@ -58,7 +58,7 @@ function Content({ children }: { children: React.ReactNode }) { Project ref - {project.ref} + {project.externalRef} )} diff --git a/apps/webapp/app/components/billing/FreePlanUsage.tsx b/apps/webapp/app/components/billing/FreePlanUsage.tsx index adc5ba3241..3aa3378d0e 100644 --- a/apps/webapp/app/components/billing/FreePlanUsage.tsx +++ b/apps/webapp/app/components/billing/FreePlanUsage.tsx @@ -25,7 +25,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
- Free Plan + Free Plan
Upgrade diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 2b7a3d28c0..31a346e192 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -2,128 +2,64 @@ 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; -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, - size = "small", className, }: { environment: Environment; - size?: keyof typeof variants; className?: string; }) { - return ( - - {environmentTypeTitle(environment)} - - ); + switch (environment.type) { + case "DEVELOPMENT": + return ( + + ); + case "PRODUCTION": + return ( + + ); + case "STAGING": + case "PREVIEW": + return ( + + ); + } } -export function EnvironmentLabel({ +export function EnvironmentCombo({ environment, - size = "small", - userName, className, }: { environment: Environment; - size?: keyof typeof variants; - userName?: string; className?: string; }) { return ( - - {environmentTitle(environment, userName)} + + + ); } -type EnvironmentWithUsername = Environment & { userName?: string }; - -export function EnvironmentLabels({ - environments, - size = "small", +export function EnvironmentLabel({ + environment, className, }: { - environments: EnvironmentWithUsername[]; - size?: keyof typeof variants; + environment: Environment; 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 ( -
- {firstDevEnvironment && ( - - )} - {otherDevEnvironments.length > 0 ? ( - - +{otherDevEnvironments.length} - - } - content={ -
- {otherDevEnvironments.map((environment, index) => ( - - ))} -
- } - /> - ) : null} - {otherEnvironments.map((environment, index) => ( - - ))} -
+ + {environmentFullTitle(environment)} + ); } @@ -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": diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index fc1fb71541..a38607aab7 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -66,3 +66,24 @@ export function MainCenteredContainer({
); } + +export function MainHorizontallyCenteredContainer({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 4955d380f9..4971a41fbc 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -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 (
-
-
- - Account - -
-
-
- - - -
- {v3Enabled && ( -
- - -
- )} -
-
- -
+
+ + Back to app + +
+
+ + + +
+
+
); diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx new file mode 100644 index 0000000000..5df3fe6aa3 --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -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 ( + setIsMenuOpen(open)} open={isMenuOpen}> + + + + +
+ {project.environments.map((env) => ( + } + isSelected={env.id === environment.id} + /> + ))} +
+ {!hasStaging && isManagedCloud && ( + <> + +
+ + + Upgrade +
+ } + isSelected={false} + /> +
+ + )} + + + ); +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx new file mode 100644 index 0000000000..90504c9dfa --- /dev/null +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -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 ( +
+
+ + Back to app + +
+
+ + + {isManagedCloud && ( + + )} + + +
+
+ +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index e8ae3bf92d..df6431f202 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -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 & { isImpersonating: boolean }; -type SideMenuProject = Pick; +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(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 (
-
-
- - -
-
-
- -
-
- - - + +
+
+
+
+ +
+ - + {environment.type === "DEVELOPMENT" && } +
+
+ +
+ + + + +
+ + + + + + + + -
-
-
- - {isFreeV3User && ( - - )} +
+
+ + {isFreeUser && ( + + )} +
); } 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({ - - {project.name ?? "Select a project"} + + + + + {project.name ?? "Select a project"} + + - {organizations.map((organization) => ( - - -
- {organization.projects.length > 0 ? ( - organization.projects.map((p) => { - const isSelected = p.id === project.id; - return ( - - {p.name} -
- } - isSelected={isSelected} - icon={FolderIcon} - /> - ); - }) - ) : ( - - )} +
+
+
+
- - ))} +
+ {organization.title} +
+ {plan && ( + + + {plan} + + + )} + + {simplur`${organization.membersCount} member[|s]`} + +
+
+
+
+ + + Settings + + + + Usage + +
+
+
+ {organization.projects.map((p) => { + const isSelected = p.id === project.id; + return ( + + {p.name} +
+ } + isSelected={isSelected} + icon={isSelected ? FolderOpenIcon : FolderIcon} + leadingIconClassName="text-indigo-500" + /> + ); + })} + +
+
+ +
+
+ + {user.isImpersonating && } +
- +
); } -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(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 ( - setProfileMenuOpen(open)}> - - - - - - -
- {user.isImpersonating && } - {user.admin && ( - - )} - - {v3Enabled && ( + setMenuOpen(open)} open={isMenuOpen}> +
+ + + Switch organization + + + +
+ {organizations.map((org) => ( } + leadingIconClassName="text-text-dimmed" + isSelected={org.id === organization.id} /> - )} + ))} +
+
- -
+ +
); } -function V3ProjectSideMenu({ - project, - organization, -}: { - project: SideMenuProject; - organization: MatchedOrganization; -}) { +function SelectorDivider() { return ( - <> - - - - - - - - + + + ); +} - - - - - +export function DevConnection() { + const { isConnected } = useDevPresence(); + + return ( + +
+ + + +
+ +
+
+ + {isConnected ? "Your dev server is connected" : "Your dev server is not connected"} + +
+
+
+ + + {isConnected + ? "Your dev server is connected to Trigger.dev" + : "Your dev server is not connected to Trigger.dev"} + +
+
+ {isConnected + + {isConnected + ? "Your local dev server is connected to Trigger.dev" + : "Your local dev server is not connected to Trigger.dev"} + +
+ {isConnected ? null : ( +
+ + + + + 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. + +
+ )} +
+ + + CLI docs + + +
+
); } diff --git a/apps/webapp/app/components/navigation/SideMenuHeader.tsx b/apps/webapp/app/components/navigation/SideMenuHeader.tsx index 8502ea0e35..83741a6c7a 100644 --- a/apps/webapp/app/components/navigation/SideMenuHeader.tsx +++ b/apps/webapp/app/components/navigation/SideMenuHeader.tsx @@ -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 (
- - {title} - +

{title}

{children !== undefined ? ( setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 036c325cdd..278bfd9188 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -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; + icon?: RenderIcon; activeIconColor?: string; inactiveIconColor?: string; - trailingIcon?: React.ComponentType; + trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; to: string; badge?: string; target?: AnchorHTMLAttributes["target"]; - subItem?: boolean; }) { const pathName = usePathName(); const isActive = pathName === to; return ( diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx new file mode 100644 index 0000000000..e6d4e47593 --- /dev/null +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -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 ( +
+
+

{title}

+ + + +
+ + + + {children} + + + +
+ ); +} diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx new file mode 100644 index 0000000000..3c62c0900b --- /dev/null +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -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; +export type IconAvatar = Extract; +export type ImageAvatar = Extract; +export type LettersAvatar = Extract; + +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 ; + case "letters": + return ( + + ); + case "image": + return ; + } +} + +export const avatarIcons: Record>> = { + "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(null); + const textRef = useRef(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 ( + + {/* This is the square container */} + + + {letters} + + + + ); +} + +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 ( + + + + ); +} + +function AvatarImage({ avatar, className }: { avatar: ImageAvatar; className?: string }) { + return ( + + Organization avatar + + ); +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 9697b77699..c5244171f8 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -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; - TrailingIcon?: React.ComponentType; + LeadingIcon?: RenderIcon; + TrailingIcon?: RenderIcon; trailingIconClassName?: string; leadingIconClassName?: string; fullWidth?: boolean; @@ -220,7 +221,8 @@ export function ButtonContent(props: ButtonContentPropsType) { )} > {LeadingIcon && ( - | React.ReactNode; diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 3a05575d4f..6fc0bffe6f 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -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; + 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) { const ref = React.useRef(null); - useShortcutKeys({ + useShortcutKeys.useShortcutKeys({ shortcut: shortcut, action: (e) => { e.preventDefault(); @@ -158,7 +161,7 @@ function PopoverArrowTrigger({ {children} - diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx index 537b1715ce..928f587afd 100644 --- a/apps/webapp/app/components/primitives/RadioButton.tsx +++ b/apps/webapp/app/components/primitives/RadioButton.tsx @@ -70,7 +70,7 @@ export function RadioButtonCircle({ return (
diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index e4314d4b0f..38fd1525c5 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -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 = { diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 4ec04d0132..80b1427cad 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -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 ( - + {button} (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; -type DisplayableEnvironment = Pick & { - 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 (
- + {hasFilters && (
), }, - { name: "environments", title: "Environment", icon: }, { name: "created", title: "Created", icon: }, { name: "daterange", title: "Custom date range", icon: }, { name: "batch", title: "Batch ID", icon: }, @@ -157,11 +144,10 @@ function FilterMenu(props: BatchFiltersProps) { ); } -function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) { +function AppliedFilters() { return ( <> - @@ -183,8 +169,6 @@ function Menu(props: MenuProps) { return ; case "statuses": return props.setFilterType(undefined)} {...props} />; - case "environments": - return props.setFilterType(undefined)} {...props} />; case "created": return props.setFilterType(undefined)} {...props} />; case "daterange": diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index e29b8a9e4b..f6faab1923 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -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 (
- +
); }} @@ -143,7 +143,7 @@ function ReplayForm({ {(matches) => matches.map((env) => ( - + )) } diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 8b8a3eecec..04b4c01cc2 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -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; -type DisplayableEnvironment = Pick & { - 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 = [
), }, - { name: "environments", title: "Environment", icon: }, { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, { name: "created", title: "Created", icon: }, @@ -217,11 +202,10 @@ function FilterMenu(props: RunFiltersProps) { ); } -function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) { +function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) { return ( <> - @@ -248,8 +232,7 @@ function Menu(props: MenuProps) { return ; case "statuses": return props.setFilterType(undefined)} {...props} />; - case "environments": - return props.setFilterType(undefined)} {...props} />; + case "tasks": return props.setFilterType(undefined)} {...props} />; case "created": diff --git a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx index 4e686bc7b6..5e02e772f9 100644 --- a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx +++ b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx @@ -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; const All = "ALL"; -type DisplayableEnvironment = Pick & { - 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 - - - - Queued Activity (7d) Avg. duration - Environments Go to page {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 ( @@ -372,9 +354,6 @@ export default function Page() { - - -
- ) : ( + ) : environment.type === "DEVELOPMENT" ? ( - + + + ) : ( + + )} @@ -444,45 +427,6 @@ export default function Page() { ); } -function CreateTaskInstructions() { - return ( - -
-
- Get setup in 3 minutes -
- - I'm stuck! - - } - defaultValue="help" - /> -
-
- - - - - You'll notice a new folder in your project called{" "} - trigger. We've added a very simple example task - in here to help you get started. - - - - - - - - - This page will automatically refresh. - -
-
- ); -} - function UserHasNoTasks() { const [open, setOpen] = useState(false); @@ -639,6 +583,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps) 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 }) { } /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts similarity index 76% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts index 2f02562f21..99dc07f12a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new.connect-to-slack.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts @@ -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, + }) ); } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx similarity index 94% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx index 4ef306f1df..526798cd78 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx @@ -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(option ?? "EMAIL"); const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState(); @@ -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() { {alertTypes.error} - - - - + + + {environmentTypes.error} {form.error} @@ -436,7 +428,7 @@ export default function Page() { } cancelButton={ Cancel diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx similarity index 58% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 03fb5cfae5..08f40cffb6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -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(); - 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() { -
-
- Project alerts - {alertChannels.length > 0 && !requiresUpgrade && ( - - New alert - - )} -
- - - - Name - Alert types - Environments - Channel - Enabled - Actions - - - - {alertChannels.length > 0 ? ( - alertChannels.map((alertChannel) => ( - - - {alertChannel.name} - - - {alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")} - - - {alertChannel.environmentTypes.map((environmentType) => ( - 0 ? ( +
+
+ Project alerts + {alertChannels.length > 0 && !requiresUpgrade && ( + + New alert + + )} +
+
+ + + Name + Alert types + Channel + Enabled + Environments + Actions + + + + {alertChannels.length > 0 ? ( + alertChannels.map((alertChannel) => ( + + + {alertChannel.name} + + + {alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")} + + + + + + - ))} - - - - - - + +
+ {alertChannel.environmentTypes.map((environmentType) => ( + + ))} +
+
+ + {alertChannel.enabled ? ( + + ) : ( + + )} + + + } + className={ + alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50" + } /> +
+ )) + ) : ( + + +
+ + You haven't created any project alerts yet + + + Get alerted when runs or deployments fail, or when deployments succeed in + both Prod and Staging environments. + + + New alert + +
- - {alertChannel.enabled ? ( - - ) : ( - - )} - - - } - className={ - alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50" - } - />
- )) - ) : ( - - -
- - You haven't created any project alerts yet - - - Get alerted when runs or deployments fail, or when deployments succeed in - both Prod and Staging environments. - - - New alert - -
-
-
- )} -
-
-
-
-
- - - - - -
- } - content={`${Math.round((limits.used / limits.limit) * 100)}%`} - /> -
- {requiresUpgrade ? ( - - You've used all {limits.limit} of your available alerts. Upgrade your plan to - enable more. - - ) : ( - - You've used {limits.used}/{limits.limit} of your alerts. - - )} - - - Upgrade - + )} + + +
+
+
+ + + + + +
+ } + content={`${Math.round((limits.used / limits.limit) * 100)}%`} + /> +
+ {requiresUpgrade ? ( + + You've used all {limits.limit} of your available alerts. Upgrade your plan + to enable more. + + ) : ( + + You've used {limits.used}/{limits.limit} of your alerts. + + )} + + + Upgrade + +
-
+ ) : environment.type === "DEVELOPMENT" ? ( + + + + ) : ( + + + + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx similarity index 81% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 6608fb69b3..7d83711f46 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -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() { Secret key Key generated Latest version - Env vars Actions @@ -107,7 +106,7 @@ export default function Page() { {environments.map((environment) => ( - + {environment.latestVersion ?? "–"} - {environment.environmentVariableCount} ))} + {!hasStaging && ( + + + + + + + Upgrade to get staging environment + + + + + + + )} @@ -144,22 +164,6 @@ export default function Page() { backend. - - {!hasStaging && ( -
- - - Upgrade to add a Staging environment - - - Upgrade - -
- )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx similarity index 85% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index 239747953e..8fd0fe8861 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -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(); - const project = useProject(); return ( @@ -102,7 +107,6 @@ export default function Page() { - -
-
- -
- + {!hasFilters && batches.length === 0 ? ( + + + + ) : ( +
+
+ +
+ +
-
- -
+ +
+ )} ); @@ -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 ( ID - Env @@ -192,18 +202,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { ) : ( batches.map((batch, index) => { - const path = v3BatchRunsPath(organization, project, batch); + const path = v3BatchRunsPath(organization, project, environment, batch); return ( {batch.friendlyId} - - - + {batch.batchVersion === "v1" ? ( - }> + + +
+ +
+
+ + } + > Error loading environments

}> {(environments) => }
@@ -124,7 +134,7 @@ export default function Page() { Upgrade for more concurrency @@ -145,7 +155,7 @@ function EnvironmentsTable({ environments }: { environments: Environment[] }) { {environments.map((environment) => ( - + {environment.queued} {environment.concurrency} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx similarity index 94% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments.$deploymentParam/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 30790de297..6a064e4995 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -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(); const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const location = useLocation(); const user = useUser(); - const { deployment } = useTypedLoaderData(); const page = new URLSearchParams(location.search).get("page"); - const usernameForEnv = - user.id !== deployment.environment.userId ? deployment.environment.userName : undefined; - return (
@@ -107,7 +108,9 @@ export default function Page() { Environment - + diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx similarity index 81% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 98a8b0719c..32945e7375 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -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(); const hasDeployments = totalPages > 0; @@ -137,7 +126,6 @@ export default function Page() { Deploy - Env Version {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() { )}
- - - {deployment.version} @@ -248,7 +227,7 @@ export default function Page() { ); }) ) : ( - + No deploys match your filters @@ -262,8 +241,14 @@ export default function Page() {
)} + ) : environment.type === "DEVELOPMENT" ? ( + + + ) : ( - + + + )} @@ -281,50 +266,6 @@ export default function Page() { ); } -function CreateDeploymentInstructions() { - const organization = useOrganization(); - const project = useProject(); - - return ( - - - - 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{" "} - - set your environment variables - {" "} - first. - -
- - Deploy with the CLI - - - Deploy with GitHub actions - -
-
-
- ); -} - function DeploymentActionsCell({ deployment, path, @@ -386,7 +327,7 @@ function DeploymentActionsCell({
-
+
Dev environment variables specified here will be overridden by ones in your .env file when running locally. - {!hasStaging && ( -
- - - Upgrade to add a Staging environment - - - Upgrade - -
- )}
@@ -409,7 +401,7 @@ function EditEnvironmentVariablePanel({ className="flex items-center justify-end" htmlFor={`values[${index}].value`} > - + ["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; export default function Page() { const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData(); - const user = useUser(); const organization = useOrganization(); const project = useProject(); - - const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined; + const environment = useEnvironment(); return ( <> - Run #{run.number} - -
- } + title={`Run #${run.number}`} /> @@ -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} /> @@ -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(null); const timelineScrollRef = useRef(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 }) => ( <>
- {events.length === 1 && environmentType === "DEVELOPMENT" && ( - - )} + {!isCompleted && + environmentType === "DEVELOPMENT" && + index === displayEvents.length - 1 && } )} 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 (
- + } + className="mt-2" + >
- - Runs usually start within 1 second in{" "} - . Check you're running the - CLI: npx trigger.dev@latest dev + + Your local dev server is not connectedr. Check you're running the CLI: +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx similarity index 91% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 74638b6034..b651bc5866 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -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(); const navigation = useNavigation(); const isLoading = navigation.state !== "idle"; - const project = useProject(); return ( <> @@ -210,7 +217,6 @@ export default function Page() { >
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 }) { Cancel {selectedItems.size} runs? - - 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. - + 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. + {[...selectedItems].map((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 }) { Replay runs? - - 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. - + 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. + {[...selectedItems].map((runId) => ( ))} @@ -448,6 +454,7 @@ function CreateFirstTaskInstructions() { function RunTaskInstructions() { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); return ( How to run your tasks @@ -458,7 +465,7 @@ function RunTaskInstructions() { page. { 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() {
{schedule.friendlyId} {schedule.timezone} - Environments + Environment - +
+ {schedule.environments.map((env) => ( + + ))} +
{isImperative && ( @@ -408,7 +419,9 @@ export default function Page() {
Edit schedule diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.edit.$scheduleParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx similarity index 72% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.edit.$scheduleParam/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx index 45457bc85a..16c940df9c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.edit.$scheduleParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam/route.tsx @@ -1,14 +1,15 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server"; import { requireUserId } from "~/services/session.server"; -import { ProjectParamSchema, v3ScheduleParams, v3SchedulesPath } from "~/utils/pathBuilder"; +import { v3ScheduleParams, v3SchedulesPath } from "~/utils/pathBuilder"; import { humanToCronSupported } from "~/v3/humanToCron.server"; -import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route"; +import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, organizationSlug, scheduleParam } = v3ScheduleParams.parse(params); + const { projectParam, organizationSlug, envParam, scheduleParam } = + v3ScheduleParams.parse(params); const presenter = new EditSchedulePresenter(); const result = await presenter.call({ @@ -18,7 +19,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); if (result.schedule?.type === "DECLARATIVE") { - throw redirect(v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam })); + throw redirect( + v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }) + ); } return typedjson({ ...result, showGenerateField: humanToCronSupported }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx similarity index 90% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.new/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index c1ed827e31..f91e2c720a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -1,10 +1,10 @@ -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server"; import { requireUserId } from "~/services/session.server"; import { ProjectParamSchema } from "~/utils/pathBuilder"; import { humanToCronSupported } from "~/v3/humanToCron.server"; -import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route"; +import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx similarity index 85% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index 97460665f4..1c26d5d76d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -1,20 +1,16 @@ -import { ClockIcon, LockOpenIcon, PlusIcon, RectangleGroupIcon } from "@heroicons/react/20/solid"; +import { ClockIcon, PlusIcon, RectangleGroupIcon } from "@heroicons/react/20/solid"; +import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react"; -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +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 { Feedback } from "~/components/Feedback"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; -import { - ScheduleTypeCombo, - ScheduleTypeIcon, - scheduleTypeName, -} from "~/components/runs/v3/ScheduleType"; import { Dialog, DialogContent, @@ -43,8 +39,14 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { ScheduleFilters, ScheduleListFilters } from "~/components/runs/v3/ScheduleFilters"; +import { + ScheduleTypeCombo, + ScheduleTypeIcon, + scheduleTypeName, +} from "~/components/runs/v3/ScheduleType"; import { useOrganization } from "~/hooks/useOrganizations"; import { usePathName } from "~/hooks/usePathName"; import { useProject } from "~/hooks/useProject"; @@ -55,17 +57,18 @@ import { ScheduleListPresenter, } from "~/presenters/v3/ScheduleListPresenter.server"; import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; import { - ProjectParamSchema, + EnvironmentParamSchema, docsPath, v3BillingPath, v3NewSchedulePath, v3SchedulePath, } from "~/utils/pathBuilder"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; -import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { cn } from "~/utils/cn"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { SchedulesNoneAttached, SchedulesNoPossibleTaskPanel } from "~/components/BlankStatePanels"; export const meta: MetaFunction = () => { return [ @@ -77,18 +80,23 @@ export const meta: MetaFunction = () => { export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, organizationSlug } = ProjectParamSchema.parse(params); - - const url = new URL(request.url); - const s = Object.fromEntries(url.searchParams.entries()); - const filters = ScheduleListFilters.parse(s); + 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) { + return redirectWithErrorMessage("/", request, "Environment not found"); + } + + const url = new URL(request.url); + const s = Object.fromEntries(url.searchParams.entries()); + const filters = ScheduleListFilters.parse(s); + filters.environments = [environment.id]; + const presenter = new ScheduleListPresenter(); const list = await presenter.call({ userId, @@ -112,6 +120,7 @@ export default function Page() { const location = useLocation(); const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const pathName = usePathName(); const plan = useCurrentPlan(); @@ -185,7 +194,7 @@ export default function Page() { ) : (
{possibleTasks.length === 0 ? ( - + + + ) : schedules.length === 0 && !hasFilters ? ( - + + + ) : ( <>
- +
- - - You have no scheduled tasks in your project. Before you can schedule a task you need to - create a schedules.task. - - - View the docs - - - - ); -} - -function AttachYourFirstScheduleInstructions() { - const organization = useOrganization(); - const project = useProject(); - const location = useLocation(); - - return ( - - - - Scheduled tasks will only run automatically if you connect a schedule to them, you can do - this in the dashboard or using the SDK. - -
- - Use the dashboard - - - Use the SDK - -
-
-
- ); -} - function SchedulesTable({ schedules, hasFilters, @@ -387,6 +332,7 @@ function SchedulesTable({ }) { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const location = useLocation(); const { scheduleParam } = useParams(); @@ -457,7 +403,9 @@ function SchedulesTable({ There are no matches for your filters ) : ( schedules.map((schedule) => { - const path = `${v3SchedulePath(organization, project, schedule)}${location.search}`; + const path = `${v3SchedulePath(organization, project, environment, schedule)}${ + location.search + }`; const isSelected = scheduleParam === schedule.friendlyId; const cellClass = schedule.active ? "" : "opacity-50"; return ( @@ -505,7 +453,11 @@ function SchedulesTable({ : "N/A"} - +
+ {schedule.environments.map((env) => ( + + ))} +
{schedule.type === "IMPERATIVE" ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx similarity index 71% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.settings/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 139ed96a22..2a57372dba 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,12 +1,16 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { FolderIcon } from "@heroicons/react/20/solid"; -import { Form, MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { ActionFunction, json } from "@remix-run/server-runtime"; +import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { InlineCode } from "~/components/code/InlineCode"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { ClipboardField } from "~/components/primitives/ClipboardField"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -144,49 +148,51 @@ export default function Page() { -
+
-
- - - - - This goes in your{" "} - trigger.config file. - - -
- - - +
- - - {projectName.error} + + + + This goes in your{" "} + trigger.config file. + - - Rename project - - } - />
- + +
+ +
+ + + + {projectName.error} + + + Rename project + + } + /> +
+
+
-
+
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.tasks.stream/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.stream/route.tsx similarity index 56% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.tasks.stream/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.stream/route.tsx index e16ba2bac7..194dd0bec3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.tasks.stream/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.stream/route.tsx @@ -1,13 +1,19 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { TasksStreamPresenter } from "~/presenters/v3/TasksStreamPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { ProjectParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; 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 presenter = new TasksStreamPresenter(); - return presenter.call({ request, projectSlug: projectParam, organizationSlug, userId }); + return presenter.call({ + request, + projectSlug: projectParam, + environmentSlug: envParam, + organizationSlug, + userId, + }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx similarity index 92% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx index 5869e6bc74..99fa18a827 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx @@ -2,10 +2,10 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { BeakerIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useSubmit } from "@remix-run/react"; -import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { TaskRunStatus } from "@trigger.dev/database"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { type TaskRunStatus } from "@trigger.dev/database"; import { useCallback, useEffect, useRef, useState } from "react"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { JSONEditor } from "~/components/code/JSONEditor"; import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; import { Button } from "~/components/primitives/Buttons"; @@ -31,16 +31,19 @@ import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TextLink } from "~/components/primitives/TextLink"; import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { TimezoneList } from "~/components/scheduled/timezones"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useSearchParams } from "~/hooks/useSearchParam"; import { redirectBackWithErrorMessage, redirectWithErrorMessage, redirectWithSuccessMessage, } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { - ScheduledRun, - StandardRun, - TestTask, + type ScheduledRun, + type StandardRun, + type TestTask, TestTaskPresenter, } from "~/presenters/v3/TestTaskPresenter.server"; import { logger } from "~/services/logger.server"; @@ -53,22 +56,31 @@ import { TestTaskData } from "~/v3/testTask"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, organizationSlug, taskParam } = v3TaskParamsSchema.parse(params); + const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params); - //need an environment - const searchParams = new URL(request.url).searchParams; - const environment = searchParams.get("environment"); + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { - return redirect(v3TestPath({ slug: organizationSlug }, { slug: projectParam })); + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); } const presenter = new TestTaskPresenter(); try { const result = await presenter.call({ userId, - projectSlug: projectParam, + projectId: project.id, taskIdentifier: taskParam, - environmentSlug: environment, + environment: environment, }); return typedjson(result); @@ -83,7 +95,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export const action: ActionFunction = async ({ request, params }) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, taskParam } = v3TaskParamsSchema.parse(params); + const { organizationSlug, projectParam, envParam, taskParam } = v3TaskParamsSchema.parse(params); const formData = await request.formData(); const submission = parse(formData, { schema: TestTaskData }); @@ -107,6 +119,7 @@ export const action: ActionFunction = async ({ request, params }) => { v3RunSpanPath( { slug: organizationSlug }, { slug: projectParam }, + { slug: envParam }, { friendlyId: run.friendlyId }, { spanId: run.spanId } ), @@ -156,6 +169,7 @@ export default function Page() { const startingJson = "{\n\n}"; function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: StandardRun[] }) { + const environment = useEnvironment(); const { value, replace } = useSearchParams(); const tab = value("tab"); @@ -195,7 +209,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa payload: currentPayloadJson.current, metadata: currentMetadataJson.current, taskIdentifier: task.taskIdentifier, - environmentId: task.environment.id, + environmentId: environment.id, }, { action: "", @@ -312,7 +326,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa This test will run in - +
+ } + /> + + +
+ +
+ Danger zone +
+ +
+ + + + {organizationSlug.error} + {deleteForm.error} + + This change is irreversible, so please be certain. Type in the Organization + slug {organization.slug} and + then press Delete. + + + + Delete organization + + } + /> +
+
+
+
+ + + + ); +} + +function LogoForm({ organization }: { organization: { avatar: Avatar } }) { + const navigation = useNavigation(); + + const isSubmitting = + navigation.state != "idle" && navigation.formData?.get("action") === "avatar"; + + const avatar = navigation.formData + ? avatarFromFormData(navigation.formData) ?? organization.avatar + : organization.avatar; + + const hex = "hex" in avatar ? avatar.hex : defaultAvatarHex; + + return ( +
+ + +
+
+ +
+ {/* Letters */} +
+ + + + +
+ {/* Icons */} + {Object.entries(avatarIcons).map(([name]) => ( +
+ + + + + +
+ ))} + {/* Hex */} + +
+
+
+ ); +} + +function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) { + return ( + + + + + +
+ + + {"name" in avatar && } + {defaultAvatarColors.map((color) => ( + + ))} +
+
+
+ ); +} + +function avatarFromFormData(formData: FormData): Avatar | undefined { + const action = formData.get("action"); + if (!action || action !== "avatar") { + return undefined; + } + + const type = formData.get("type"); + const hex = formData.get("hex"); + + if (type === "letters") { + return { + type: "letters", + hex: hex as string, + }; + } + + if (type === "icon") { + return { + type: "icon", + name: formData.get("name") as string, + hex: hex as string, + }; + } + + return undefined; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx similarity index 87% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx index 29b599fe5e..f42c77ad50 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.billing/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.billing/route.tsx @@ -1,6 +1,6 @@ import { CalendarDaysIcon, StarIcon } from "@heroicons/react/20/solid"; -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { PlanDefinition } from "@trigger.dev/platform/v3"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type PlanDefinition } from "@trigger.dev/platform/v3"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { LinkButton } from "~/components/primitives/Buttons"; @@ -16,7 +16,8 @@ import { v3StripePortalPath, } from "~/utils/pathBuilder"; import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan"; -import { MetaFunction } from "@remix-run/react"; +import { type MetaFunction } from "@remix-run/react"; +import { Callout } from "~/components/primitives/Callout"; export const meta: MetaFunction = () => { return [ @@ -64,6 +65,10 @@ export async function loader({ params, request }: LoaderFunctionArgs) { (periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24) ); + // Extract 'message' from search params + const url = new URL(request.url); + const message = url.searchParams.get("message"); + return typedjson({ ...plans, ...currentPlan, @@ -71,12 +76,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) { periodStart, periodEnd, daysRemaining, + message, }); } export default function ChoosePlanPage() { - const { plans, v3Subscription, organizationSlug, periodStart, periodEnd, daysRemaining } = - useTypedLoaderData(); + const { + plans, + v3Subscription, + organizationSlug, + periodStart, + periodEnd, + daysRemaining, + message, + } = useTypedLoaderData(); return ( @@ -102,6 +115,11 @@ export default function ChoosePlanPage() {
+ {message && ( + + {message} + + )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx similarity index 69% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 0027aa8308..5cb99ddbab 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -1,15 +1,19 @@ import { useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { EnvelopeIcon, LockOpenIcon, TrashIcon, UserPlusIcon } from "@heroicons/react/20/solid"; -import { Form, MetaFunction, useActionData } from "@remix-run/react"; -import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { Form, type MetaFunction, useActionData } from "@remix-run/react"; +import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; import { useState } from "react"; -import { UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import { z } from "zod"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; import { Alert, AlertCancel, @@ -158,82 +162,88 @@ export default function Page() { - - Members ({limits.used}/{limits.limit}) - -
    - {members.map((member) => ( -
  • - -
    - - {member.user.name}{" "} - {member.user.id === user.id && (You)} - - {member.user.email} -
    -
    - -
    -
  • - ))} -
- - {invites.length > 0 && ( - <> - Pending invites -
    - {invites.map((invite) => ( -
  • -
    - -
    -
    - {invite.email} - - Invite sent {} - -
    -
    - - -
    -
  • - ))} -
- - )} - - {requiresUpgrade ? ( - - - You've used all {limits.limit} of your available team members. Upgrade your plan to - enable more. - - - ) : ( -
- + + Members ({limits.used}/{limits.limit}) + +
    + {members.map((member) => ( +
  • + +
    + + {member.user.name}{" "} + {member.user.id === user.id && (You)} + + {member.user.email} +
    +
    + +
    +
  • + ))} +
+ + {invites.length > 0 && ( + <> + Pending invites +
    + {invites.map((invite) => ( +
  • +
    + +
    +
    + {invite.email} + + Invite sent {} + +
    +
    + + +
    +
  • + ))} +
+ + )} + + {requiresUpgrade ? ( + - Invite a team member -
-
- )} + + You've used all {limits.limit} of your available team members. Upgrade your plan to + enable more. + + + ) : ( +
+ + Invite a team member + +
+ )} +
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx similarity index 98% rename from apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx rename to apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index 28df91d1e1..9e92c27f2b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -1,6 +1,6 @@ import { InformationCircleIcon } from "@heroicons/react/20/solid"; -import { Await, MetaFunction } from "@remix-run/react"; -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { Await, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { Suspense } from "react"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; @@ -9,7 +9,7 @@ import { URL } from "url"; import { UsageBar } from "~/components/billing/UsageBar"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { - ChartConfig, + type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, @@ -31,7 +31,7 @@ import { import { prisma } from "~/db.server"; import { featuresForRequest } from "~/features.server"; import { useSearchParams } from "~/hooks/useSearchParam"; -import { UsagePresenter, UsageSeriesData } from "~/presenters/v3/UsagePresenter.server"; +import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server"; import { requireUserId } from "~/services/session.server"; import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter"; import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder"; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index ca338afac9..32f77ef904 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -1,277 +1,19 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { Form, MetaFunction, useActionData, useNavigation } from "@remix-run/react"; -import { ActionFunction, json } from "@remix-run/server-runtime"; -import { redirect } from "remix-typedjson"; -import { z } from "zod"; -import { InlineCode } from "~/components/code/InlineCode"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { Fieldset } from "~/components/primitives/Fieldset"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { FormError } from "~/components/primitives/FormError"; -import { Header2 } from "~/components/primitives/Headers"; -import { Hint } from "~/components/primitives/Hint"; -import { Input } from "~/components/primitives/Input"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; -import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; -import { prisma } from "~/db.server"; +import { Outlet } from "@remix-run/react"; +import { AppContainer, MainBody } from "~/components/layout/AppLayout"; +import { OrganizationSettingsSideMenu } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; -import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { - clearCurrentProjectId, - commitCurrentProjectSession, -} from "~/services/currentProject.server"; -import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; -import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; -import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder"; - -export const meta: MetaFunction = () => { - return [ - { - title: `Organization settings | Trigger.dev`, - }, - ]; -}; - -export function createSchema( - constraints: { - getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string }; - } = {} -) { - return z.discriminatedUnion("action", [ - z.object({ - action: z.literal("rename"), - organizationName: z - .string() - .min(3, "Organization name must have at least 3 characters") - .max(50), - }), - z.object({ - action: z.literal("delete"), - organizationSlug: z.string().superRefine((slug, ctx) => { - if (constraints.getSlugMatch === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: conform.VALIDATION_UNDEFINED, - }); - } else { - const { isMatch, organizationSlug } = constraints.getSlugMatch(slug); - if (isMatch) { - return; - } - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `The slug must match ${organizationSlug}`, - }); - } - }), - }), - ]); -} - -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug } = params; - if (!organizationSlug) { - return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); - } - - const formData = await request.formData(); - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === organizationSlug, organizationSlug }; - }, - }); - const submission = parse(formData, { schema }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - try { - switch (submission.value.action) { - case "rename": { - await prisma.organization.update({ - where: { - slug: organizationSlug, - members: { - some: { - userId, - }, - }, - }, - data: { - title: submission.value.organizationName, - }, - }); - - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - `Organization renamed to ${submission.value.organizationName}` - ); - } - case "delete": { - const deleteOrganizationService = new DeleteOrganizationService(); - try { - await deleteOrganizationService.call({ organizationSlug, userId, request }); - - //we need to clear the project from the session - const removeProjectIdSession = await clearCurrentProjectId(request); - return redirect(rootPath(), { - headers: { - "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession), - }, - }); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); - logger.error("Organization could not be deleted", { - error: errorMessage, - }); - return redirectWithErrorMessage( - organizationSettingsPath({ slug: organizationSlug }), - request, - errorMessage - ); - } - } - } - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); - } -}; export default function Page() { const organization = useOrganization(); - const lastSubmission = useActionData(); - const navigation = useNavigation(); - - const [renameForm, { organizationName }] = useForm({ - id: "rename-organization", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema(), - }); - }, - }); - - const [deleteForm, { organizationSlug }] = useForm({ - id: "delete-organization", - // TODO: type this - lastSubmission: lastSubmission as any, - shouldValidate: "onInput", - shouldRevalidate: "onSubmit", - onValidate({ formData }) { - return parse(formData, { - schema: createSchema({ - getSlugMatch: (slug) => ({ - isMatch: slug === organization.slug, - organizationSlug: organization.slug, - }), - }), - }); - }, - }); - - const isRenameLoading = - navigation.formData?.get("action") === "rename" && - (navigation.state === "submitting" || navigation.state === "loading"); - - const isDeleteLoading = - navigation.formData?.get("action") === "delete" && - (navigation.state === "submitting" || navigation.state === "loading"); return ( - - - - - - -
-
-
- -
- - - - {organizationName.error} - - - Rename organization - - } - /> -
-
-
- -
- Danger zone -
- -
- - - - {organizationSlug.error} - {deleteForm.error} - - This change is irreversible, so please be certain. Type in the Organization slug{" "} - {organization.slug} and then - press Delete. - - - - Delete organization - - } - /> -
-
-
-
-
-
+ +
+ + + + +
+
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 4941f148ce..7700b7e20d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -1,23 +1,21 @@ -import { Outlet, ShouldRevalidateFunction, UIMatch } from "@remix-run/react"; +import { Outlet, type ShouldRevalidateFunction, type UIMatch } from "@remix-run/react"; import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { z } from "zod"; import { RouteErrorDisplay } from "~/components/ErrorDisplay"; -import { MainBody } from "~/components/layout/AppLayout"; -import { SideMenu } from "~/components/navigation/SideMenu"; import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useTypedMatchesData } from "~/hooks/useTypedMatchData"; -import { useUser } from "~/hooks/useUser"; import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server"; import { getImpersonationId } from "~/services/impersonation.server"; -import { getCachedUsage, getCurrentPlan, getUsage } from "~/services/platform.v3.server"; -import { requireUserId } from "~/services/session.server"; +import { getCachedUsage, getCurrentPlan } from "~/services/platform.v3.server"; +import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; import { organizationPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ organizationSlug: z.string(), projectParam: z.string().optional(), + envParam: z.string().optional(), }); export function useCurrentPlan(matches?: UIMatch[]) { @@ -42,6 +40,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { if (current.data.projectParam !== next.data.projectParam) { return true; } + if (current.data.envParam !== next.data.envParam) { + return true; + } } // This prevents revalidation when there are search params changes @@ -51,17 +52,18 @@ export const shouldRevalidate: ShouldRevalidateFunction = (params) => { // IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); const impersonationId = await getImpersonationId(request); - const { organizationSlug, projectParam } = ParamsSchema.parse(params); + const { organizationSlug, projectParam, envParam } = ParamsSchema.parse(params); const orgsPresenter = new OrganizationsPresenter(); - const { organizations, organization, project } = await orgsPresenter.call({ - userId, + const { organizations, organization, project, environment } = await orgsPresenter.call({ + user, request, organizationSlug, projectSlug: projectParam, + environmentSlug: envParam, }); telemetry.organization.identify({ organization }); @@ -95,31 +97,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { organizations, organization, project, + environment, isImpersonating: !!impersonationId, currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } }, }); }; export default function Organization() { - const { organization, project, organizations, isImpersonating } = - useTypedLoaderData(); - const user = useUser(); - - return ( - <> -
- - - - -
- - ); + return ; } export function ErrorBoundary() { diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 96d95a9aee..247fa4124c 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,11 +1,11 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; import { EnvelopeIcon, UserCircleIcon } from "@heroicons/react/20/solid"; -import { Form, MetaFunction, useActionData } from "@remix-run/react"; -import { ActionFunction, json } from "@remix-run/server-runtime"; +import { Form, type MetaFunction, useActionData } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; import { UserProfilePhoto } from "~/components/UserProfilePhoto"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -138,54 +138,56 @@ export default function Page() { -
- - - - -
- - - - Your teammates will see this - {name.error} - - - - - {email.error} +
+ + + + - - - + + + + Your teammates will see this + {name.error} + + + + + {email.error} + + + + + {marketingEmails.error} + + + + Update + + } /> - {marketingEmails.error} - - - - Update - - } - /> -
-
+ + +
); diff --git a/apps/webapp/app/routes/engine.v1.dev.config.ts b/apps/webapp/app/routes/engine.v1.dev.config.ts index 0a4c8e4ba5..c30412c154 100644 --- a/apps/webapp/app/routes/engine.v1.dev.config.ts +++ b/apps/webapp/app/routes/engine.v1.dev.config.ts @@ -1,5 +1,5 @@ -import { json, TypedResponse } from "@remix-run/server-runtime"; -import { DevConfigResponseBody } from "@trigger.dev/core/v3/schemas"; +import { json, type TypedResponse } from "@remix-run/server-runtime"; +import { type DevConfigResponseBody } from "@trigger.dev/core/v3/schemas"; import { z } from "zod"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; diff --git a/apps/webapp/app/routes/engine.v1.dev.presence.ts b/apps/webapp/app/routes/engine.v1.dev.presence.ts index 9e3add8c78..8ddfdf2575 100644 --- a/apps/webapp/app/routes/engine.v1.dev.presence.ts +++ b/apps/webapp/app/routes/engine.v1.dev.presence.ts @@ -40,11 +40,8 @@ export const loader = createSSELoader({ }); }, initStream: async ({ send }) => { - //todo set a string instead, with the expire on the same call - //won't need multi - // Set initial presence with more context - await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, Date.now().toString()); + await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, new Date().toISOString()); // Publish presence update await redis.publish( diff --git a/apps/webapp/app/routes/logout.tsx b/apps/webapp/app/routes/logout.tsx index c0c133f2c4..bd7cd1394b 100644 --- a/apps/webapp/app/routes/logout.tsx +++ b/apps/webapp/app/routes/logout.tsx @@ -1,36 +1,10 @@ -import { redirect, type ActionFunction, type LoaderFunction } from "@remix-run/node"; +import { type ActionFunction, type LoaderFunction } from "@remix-run/node"; import { authenticator } from "~/services/auth.server"; -import { - clearCurrentProjectId, - commitCurrentProjectSession, - getCurrentProjectId, -} from "~/services/currentProject.server"; -import { logoutPath } from "~/utils/pathBuilder"; export const action: ActionFunction = async ({ request }) => { - const projectId = await getCurrentProjectId(request); - if (projectId) { - const removeProjectIdSession = await clearCurrentProjectId(request); - return redirect(logoutPath(), { - headers: { - "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession), - }, - }); - } - return await authenticator.logout(request, { redirectTo: "/" }); }; export const loader: LoaderFunction = async ({ request }) => { - const projectId = await getCurrentProjectId(request); - if (projectId) { - const removeProjectIdSession = await clearCurrentProjectId(request); - return redirect(logoutPath(), { - headers: { - "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession), - }, - }); - } - return await authenticator.logout(request, { redirectTo: "/" }); }; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.billing.ts b/apps/webapp/app/routes/orgs.$organizationSlug.billing.ts new file mode 100644 index 0000000000..6aeec6dd17 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.billing.ts @@ -0,0 +1,7 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { OrganizationParamsSchema, v3BillingPath, v3UsagePath } from "~/utils/pathBuilder"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + return redirect(v3BillingPath({ slug: organizationSlug })); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts new file mode 100644 index 0000000000..9342712c55 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.apikeys.ts @@ -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, v3ApiKeysPath } 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(v3ApiKeysPath({ slug: organizationSlug }, project, environment)); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts new file mode 100644 index 0000000000..5a977cd5cc --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.concurrency.ts @@ -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, v3ApiKeysPath, v3ConcurrencyPath } 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(v3ConcurrencyPath({ slug: organizationSlug }, project, environment)); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.deployments.$deploymentParam.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.deployments.$deploymentParam.ts new file mode 100644 index 0000000000..021d468844 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.deployments.$deploymentParam.ts @@ -0,0 +1,43 @@ +import { redirect } from "@remix-run/router"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { ProjectParamSchema, v3DeploymentPath } from "~/utils/pathBuilder"; + +const ParamSchema = ProjectParamSchema.extend({ + deploymentParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + await requireUserId(request); + const { organizationSlug, projectParam, deploymentParam } = ParamSchema.parse(params); + + const deployment = await prisma.workerDeployment.findFirst({ + where: { + shortCode: deploymentParam, + project: { + slug: projectParam, + }, + }, + select: { + environment: true, + }, + }); + + if (!deployment) { + throw new Response("Not Found", { status: 404 }); + } + + return redirect( + v3DeploymentPath( + { + slug: organizationSlug, + }, + { slug: projectParam }, + { slug: deployment.environment.slug }, + { shortCode: deploymentParam }, + 0 + ) + ); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts new file mode 100644 index 0000000000..5f99c4a953 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.environment-variables.ts @@ -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, v3ApiKeysPath, v3EnvironmentVariablesPath } 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(v3EnvironmentVariablesPath({ slug: organizationSlug }, project, environment)); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts new file mode 100644 index 0000000000..29aa557797 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.runs.$runParam.ts @@ -0,0 +1,42 @@ +import { redirect } from "@remix-run/router"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { ProjectParamSchema, v3DeploymentPath, v3RunPath } from "~/utils/pathBuilder"; + +const ParamSchema = ProjectParamSchema.extend({ + runParam: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + await requireUserId(request); + const { organizationSlug, projectParam, runParam } = ParamSchema.parse(params); + + const run = await prisma.taskRun.findFirst({ + where: { + friendlyId: runParam, + project: { + slug: projectParam, + }, + }, + select: { + runtimeEnvironment: true, + }, + }); + + if (!run) { + throw new Response("Not Found", { status: 404 }); + } + + return redirect( + v3RunPath( + { + slug: organizationSlug, + }, + { slug: projectParam }, + { slug: run.runtimeEnvironment.slug }, + { friendlyId: runParam } + ) + ); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts new file mode 100644 index 0000000000..0948caec68 --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.$projectParam.settings.ts @@ -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, v3ProjectSettingsPath } 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(v3ProjectSettingsPath({ slug: organizationSlug }, project, environment)); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.projects.v3.$.ts b/apps/webapp/app/routes/orgs.$organizationSlug.projects.v3.$.ts new file mode 100644 index 0000000000..58eb8ea2fe --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.projects.v3.$.ts @@ -0,0 +1,11 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const organizationSlug = params.organizationSlug; + const path = params["*"]; + + const url = new URL(request.url); + url.pathname = `/orgs/${organizationSlug}/projects/${path}`; + + return redirect(url.toString()); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.team.ts b/apps/webapp/app/routes/orgs.$organizationSlug.team.ts new file mode 100644 index 0000000000..38833438dc --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.team.ts @@ -0,0 +1,7 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { OrganizationParamsSchema, organizationTeamPath, v3UsagePath } from "~/utils/pathBuilder"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + return redirect(organizationTeamPath({ slug: organizationSlug })); +}; diff --git a/apps/webapp/app/routes/orgs.$organizationSlug.usage.ts b/apps/webapp/app/routes/orgs.$organizationSlug.usage.ts new file mode 100644 index 0000000000..b054ef6a1f --- /dev/null +++ b/apps/webapp/app/routes/orgs.$organizationSlug.usage.ts @@ -0,0 +1,7 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { OrganizationParamsSchema, v3UsagePath } from "~/utils/pathBuilder"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { organizationSlug } = OrganizationParamsSchema.parse(params); + return redirect(v3UsagePath({ slug: organizationSlug })); +}; diff --git a/apps/webapp/app/routes/projects.new.ts b/apps/webapp/app/routes/projects.new.ts index ec9a67f936..604bcc0fa9 100644 --- a/apps/webapp/app/routes/projects.new.ts +++ b/apps/webapp/app/routes/projects.new.ts @@ -1,6 +1,6 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; 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 } from "~/utils/pathBuilder"; @@ -10,10 +10,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const url = new URL(request.url); - const presenter = new SelectBestProjectPresenter(); + const presenter = new SelectBestEnvironmentPresenter(); try { - const { organization } = await presenter.call({ userId: user.id, request }); + const { organization } = await presenter.call({ user: user }); //redirect them to the most appropriate project return redirect(`${newProjectPath(organization)}${url.search}`); } catch (e) { diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts b/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts index a27725c826..e4f83a13ad 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.deployments.$deploymentParam.ts @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; @@ -35,6 +35,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Redirect to the project's runs page return redirect( - `/orgs/${project.organization.slug}/projects/v3/${project.slug}/deployments/${validatedParams.deploymentParam}` + `/orgs/${project.organization.slug}/projects/${project.slug}/deployments/${validatedParams.deploymentParam}` ); } diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.environment-variables.ts b/apps/webapp/app/routes/projects.v3.$projectRef.environment-variables.ts index 0882c3a7af..320fd23056 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.environment-variables.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.environment-variables.ts @@ -34,6 +34,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Redirect to the project's runs page return redirect( - `/orgs/${project.organization.slug}/projects/v3/${project.slug}/environment-variables` + `/orgs/${project.organization.slug}/projects/${project.slug}/environment-variables` ); } diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts b/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts index 57edac645d..fe267d1f9f 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.runs.$runParam.ts @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; @@ -38,6 +38,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) { where: { friendlyId: validatedParams.runParam, }, + include: { + runtimeEnvironment: true, + }, }); if (!run) { @@ -46,8 +49,14 @@ export async function loader({ params, request }: LoaderFunctionArgs) { // Redirect to the project's runs page return redirect( - v3RunSpanPath({ slug: project.organization.slug }, { slug: project.slug }, run, { - spanId: run.spanId, - }) + v3RunSpanPath( + { slug: project.organization.slug }, + { slug: project.slug }, + run.runtimeEnvironment, + run, + { + spanId: run.spanId, + } + ) ); } diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.runs.ts b/apps/webapp/app/routes/projects.v3.$projectRef.runs.ts index 0cb788dbd0..f10f45d53a 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.runs.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.runs.ts @@ -1,7 +1,7 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { EnvSlug, isEnvSlug } from "~/models/api-key.server"; +import { type EnvSlug, isEnvSlug } from "~/models/api-key.server"; import { requireUserId } from "~/services/session.server"; const ParamsSchema = z.object({ @@ -41,15 +41,13 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const env = await getEnvFromSlug(project.id, userId, envSlug); if (env) { - url.searchParams.set("environments", env.id); + return redirect( + `/orgs/${project.organization.slug}/projects/${project.slug}/env/${envSlug}/runs${url.search}` + ); } - - url.searchParams.delete("envSlug"); } - return redirect( - `/orgs/${project.organization.slug}/projects/v3/${project.slug}/runs${url.search}` - ); + return redirect(`/orgs/${project.organization.slug}/projects/${project.slug}`); } async function getEnvFromSlug(projectId: string, userId: string, envSlug: EnvSlug) { diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.test.ts b/apps/webapp/app/routes/projects.v3.$projectRef.test.ts index 5695f28616..a853a29f5e 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.test.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.test.ts @@ -2,6 +2,7 @@ import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; +import { v3ProjectPath, v3TestPath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -33,8 +34,13 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } const url = new URL(request.url); + const environment = url.searchParams.get("environment"); - return redirect( - `/orgs/${project.organization.slug}/projects/v3/${project.slug}/test${url.search}` - ); + if (environment) { + return redirect( + v3TestPath({ slug: project.organization.slug }, { slug: project.slug }, { slug: environment }) + ); + } + + return redirect(v3ProjectPath({ slug: project.organization.slug }, { slug: project.slug })); } diff --git a/apps/webapp/app/routes/projects.v3.$projectRef.ts b/apps/webapp/app/routes/projects.v3.$projectRef.ts index 4a702ff2ab..856a93c4ac 100644 --- a/apps/webapp/app/routes/projects.v3.$projectRef.ts +++ b/apps/webapp/app/routes/projects.v3.$projectRef.ts @@ -1,4 +1,4 @@ -import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { requireUserId } from "~/services/session.server"; @@ -33,5 +33,5 @@ export async function loader({ params, request }: LoaderFunctionArgs) { } // Redirect to the project's runs page - return redirect(`/orgs/${project.organization.slug}/projects/v3/${project.slug}`); + return redirect(`/orgs/${project.organization.slug}/projects/${project.slug}`); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx new file mode 100644 index 0000000000..33ec558d2d --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dev.presence.tsx @@ -0,0 +1,129 @@ +import { $replica } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { env } from "~/env.server"; +import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server"; +import { logger } from "~/services/logger.server"; +import { createSSELoader, type SendFunction } from "~/utils/sse"; +import Redis from "ioredis"; + +export const loader = createSSELoader({ + timeout: env.DEV_PRESENCE_TTL_MS, + interval: env.DEV_PRESENCE_POLL_INTERVAL_MS, + debug: true, + handler: async ({ id, controller, debug, request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + slug: envParam, + type: "DEVELOPMENT", + orgMember: { + userId, + }, + project: { + slug: projectParam, + }, + }, + }); + + if (!environment) { + throw new Response("Not Found", { status: 404 }); + } + + const presenceKey = DevPresenceStream.getPresenceKey(environment.id); + const presenceChannel = DevPresenceStream.getPresenceChannel(environment.id); + + // Create two Redis clients - one for subscribing and one for regular commands + const redisConfig = { + port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined, + host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined, + username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined, + password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined, + enableAutoPipelining: true, + ...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }), + }; + + // Subscriber client for pubsub + const subRedis = new Redis(redisConfig); + + // Command client for regular Redis commands + const cmdRedis = new Redis(redisConfig); + + const checkAndSendPresence = async (send: SendFunction) => { + try { + // Use the command client for the GET operation + const currentPresenceValue = await cmdRedis.get(presenceKey); + const isConnected = !!currentPresenceValue; + + // Format lastSeen as ISO string if it exists + let lastSeen = null; + if (currentPresenceValue) { + try { + lastSeen = new Date(currentPresenceValue).toISOString(); + } catch (e) { + // If parsing fails, use current time as fallback + lastSeen = new Date().toISOString(); + logger.warn("Failed to parse lastSeen value, using current time", { + originalValue: currentPresenceValue, + }); + } + } + + send({ + event: "presence", + data: JSON.stringify({ + type: isConnected ? "connected" : "disconnected", + environmentId: environment.id, + timestamp: new Date().toISOString(), // Also standardize this to ISO + lastSeen: lastSeen, + }), + }); + + return isConnected; + } catch (error) { + // Handle the case where the controller is closed + logger.debug("Failed to send presence data, stream might be closed", { error }); + return false; + } + }; + + return { + beforeStream: async () => { + logger.debug("Start dev presence listening SSE session", { + environmentId: environment.id, + presenceChannel, + }); + }, + initStream: async ({ send }) => { + await checkAndSendPresence(send); + + //start subscribing with the subscriber client + await subRedis.subscribe(presenceChannel); + + subRedis.on("message", async (channel, message) => { + if (channel === presenceChannel) { + try { + await checkAndSendPresence(send); + } catch (error) { + logger.error("Failed to parse presence message", { error, message }); + } + } + }); + + send({ event: "time", data: new Date().toISOString() }); + }, + iterator: async ({ send, date }) => { + await checkAndSendPresence(send); + }, + cleanup: async ({ send }) => { + await checkAndSendPresence(send); + + await subRedis.unsubscribe(presenceChannel); + await subRedis.quit(); + await cmdRedis.quit(); + }, + }; + }, +}); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx similarity index 94% rename from apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 805cf89b96..229aeb862e 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -5,10 +5,10 @@ import { QueueListIcon, } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; -import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds, - TaskRunError, + type TaskRunError, taskRunErrorEnhancer, } from "@trigger.dev/core/v3"; import { useEffect } from "react"; @@ -16,7 +16,7 @@ import { typedjson, useTypedFetcher } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; import { AdminDebugRun } from "~/components/admin/debugRun"; import { CodeBlock } from "~/components/code/CodeBlock"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { Feedback } from "~/components/Feedback"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; @@ -53,7 +53,7 @@ import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; -import { Span, SpanPresenter, SpanRun } from "~/presenters/v3/SpanPresenter.server"; +import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; @@ -68,15 +68,16 @@ import { v3SchedulePath, v3SpanParamsSchema, } from "~/utils/pathBuilder"; -import { SpanLink } from "~/v3/eventRepository.server"; import { CompleteWaitpointForm, ForceTimeout, -} from "../resources.orgs.$organizationSlug.projects.$projectParam.waitpoints.$waitpointFriendlyId.complete/route"; +} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route"; +import { useEnvironment } from "~/hooks/useEnvironment"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam, organizationSlug, runParam, spanParam } = v3SpanParamsSchema.parse(params); + const { projectParam, organizationSlug, envParam, runParam, spanParam } = + v3SpanParamsSchema.parse(params); const presenter = new SpanPresenter(); @@ -99,7 +100,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { error, }); return redirectWithErrorMessage( - v3RunPath({ slug: organizationSlug }, { slug: projectParam }, { friendlyId: runParam }), + v3RunPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { friendlyId: runParam } + ), request, `Event not found.` ); @@ -117,14 +123,15 @@ export function SpanView({ }) { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const fetcher = useTypedFetcher(); useEffect(() => { if (spanId === undefined) return; fetcher.load( - `/resources/orgs/${organization.slug}/projects/v3/${project.slug}/runs/${runParam}/spans/${spanId}` + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runParam}/spans/${spanId}` ); - }, [organization.slug, project.slug, runParam, spanId]); + }, [organization.slug, project.slug, environment.slug, runParam, spanId]); if (spanId === undefined) { return null; @@ -178,6 +185,7 @@ function SpanBody({ }) { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const { value, replace } = useSearchParams(); let tab = value("tab"); @@ -257,7 +265,11 @@ function SpanBody({ + {span.taskSlug} } @@ -310,12 +322,11 @@ function RunBody({ }) { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const isAdmin = useHasAdminAccess(); const { value, replace } = useSearchParams(); const tab = value("tab"); - const environment = project.environments.find((e) => e.id === run.environmentId); - return (
@@ -403,7 +414,9 @@ function RunBody({ {run.taskIdentifier} @@ -421,6 +434,7 @@ function RunBody({ to={v3RunSpanPath( organization, project, + environment, { friendlyId: run.relationships.root.friendlyId, }, @@ -444,6 +458,7 @@ function RunBody({ to={v3RunSpanPath( organization, project, + environment, { friendlyId: run.relationships.root.friendlyId, }, @@ -466,6 +481,7 @@ function RunBody({ to={v3RunSpanPath( organization, project, + environment, { friendlyId: run.relationships.parent.friendlyId, }, @@ -490,7 +506,7 @@ function RunBody({ + {run.batch.friendlyId} } @@ -547,22 +563,6 @@ function RunBody({ )} - - Engine version - {run.engine} - - {isAdmin && ( - <> - - Primary master queue - {run.masterQueue} - - - Secondary master queue - {run.secondaryMasterQueue} - - - )} Test run @@ -573,7 +573,7 @@ function RunBody({ Environment - + )} @@ -588,7 +588,9 @@ function RunBody({
+ {run.schedule.description} } @@ -622,7 +624,9 @@ function RunBody({ + } @@ -677,6 +681,22 @@ function RunBody({ Internal ID {run.id} + + Run Engine + {run.engine} + + {isAdmin && ( + <> + + Primary master queue + {run.masterQueue} + + + Secondary master queue + {run.secondaryMasterQueue ?? "–"} + + + )}
) : tab === "context" ? ( @@ -720,6 +740,7 @@ function RunBody({ to={v3RunSpanPath( organization, project, + environment, { friendlyId: run.friendlyId }, { spanId: run.spanId } )} @@ -866,6 +887,7 @@ function SpanEntity({ span }: { span: Span }) { const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); if (!span.entity) { //normal span @@ -927,6 +949,7 @@ function SpanEntity({ span }: { span: Span }) { const path = v3RunSpanPath( organization, project, + environment, { friendlyId: run.friendlyId }, { spanId: run.spanId } ); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx similarity index 87% rename from apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx index b0e26cbc16..1e44dbdc00 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.schedules.new/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route.tsx @@ -10,6 +10,7 @@ import { useRef, useState } from "react"; import { environmentTextClassName, environmentTitle, + EnvironmentCombo, } from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; @@ -39,13 +40,20 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m import { EditableScheduleElements } from "~/presenters/v3/EditSchedulePresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; -import { ProjectParamSchema, docsPath, v3SchedulesPath } from "~/utils/pathBuilder"; +import { + EnvironmentParamSchema, + ProjectParamSchema, + docsPath, + v3SchedulesPath, +} from "~/utils/pathBuilder"; import { CronPattern, UpsertSchedule } from "~/v3/schedules"; import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server"; import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language"; import { TimezoneList } from "~/components/scheduled/timezones"; import { logger } from "~/services/logger.server"; import { Spinner } from "~/components/primitives/Spinner"; +import { cond } from "effect/STM"; +import { useEnvironment } from "~/hooks/useEnvironment"; const cronFormat = `* * * * * ┬ ┬ ┬ ┬ ┬ @@ -58,7 +66,7 @@ const cronFormat = `* * * * * 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); const formData = await request.formData(); const submission = parse(formData, { schema: UpsertSchedule }); @@ -91,7 +99,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const result = await createSchedule.call(project.id, submission.value); return redirectWithSuccessMessage( - v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }), + v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created" ); @@ -100,7 +108,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const errorMessage = `Something went wrong. Please try again.`; return redirectWithErrorMessage( - v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }), + v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, errorMessage ); @@ -132,6 +140,7 @@ export function UpsertScheduleForm({ const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const location = useLocation(); const [form, { taskIdentifier, cron, timezone, externalId, environments, deduplicationKey }] = @@ -184,7 +193,7 @@ export function UpsertScheduleForm({ return (
@@ -307,34 +316,44 @@ export function UpsertScheduleForm({
)} - +
- {possibleEnvironments.map((environment) => ( - - {environmentTitle(environment, environment.userName)} - - } - defaultChecked={ - schedule?.instances.find((i) => i.environmentId === environment.id) !== - undefined - } - variant="button" - /> - ))} + {/* This first condition supports old schedules where we let you have multiple environments */} + {schedule && schedule?.environments.length > 1 ? ( + possibleEnvironments.map((environment) => ( + + {environmentTitle(environment, environment.userName)} + + } + defaultChecked={ + schedule?.instances.find((i) => i.environmentId === environment.id) !== + undefined + } + variant="button" + /> + )) + ) : ( + <> + + + + )}
- - Select all the environments where you want this schedule to run. Note that scheduled - tasks in dev environments will only run while you are connected with the dev CLI - + {environment.type === "DEVELOPMENT" && ( + + Note that scheduled tasks in dev environments will only run while you are + connected with the dev CLI. + + )} {environments.error}
@@ -383,7 +402,7 @@ export function UpsertScheduleForm({
Cancel diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.waitpoints.$waitpointFriendlyId.complete/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx similarity index 94% rename from apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.waitpoints.$waitpointFriendlyId.complete/route.tsx rename to apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx index 0e9e9dbe86..816eeb0d06 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.waitpoints.$waitpointFriendlyId.complete/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route.tsx @@ -1,7 +1,7 @@ import { env } from "~/env.server"; import { parse } from "@conform-to/zod"; import { Form, useLocation, useNavigation, useSubmit } from "@remix-run/react"; -import { ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { conditionallyExportPacket, IOPacket, @@ -25,9 +25,10 @@ import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; -import { ProjectParamSchema, v3RunsPath } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, ProjectParamSchema, v3RunsPath } from "~/utils/pathBuilder"; import { engine } from "~/v3/runEngine.server"; import { SpinnerWhite } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; const CompleteWaitpointFormData = z.discriminatedUnion("type", [ z.object({ @@ -44,13 +45,13 @@ const CompleteWaitpointFormData = z.discriminatedUnion("type", [ }), ]); -const Params = ProjectParamSchema.extend({ +const Params = EnvironmentParamSchema.extend({ waitpointFriendlyId: z.string(), }); export const action = async ({ request, params }: ActionFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, waitpointFriendlyId } = Params.parse(params); + const { organizationSlug, projectParam, envParam, waitpointFriendlyId } = Params.parse(params); const formData = await request.formData(); const submission = parse(formData, { schema: CompleteWaitpointFormData }); @@ -181,7 +182,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const errorMessage = `Something went wrong. Please try again.`; return redirectWithErrorMessage( - v3RunsPath({ slug: organizationSlug }, { slug: projectParam }), + v3RunsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, errorMessage ); @@ -191,12 +192,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { type FormWaitpoint = Pick; export function CompleteWaitpointForm({ waitpoint }: { waitpoint: FormWaitpoint }) { - const navigation = useNavigation(); - const submit = useSubmit(); - const isLoading = navigation.state !== "idle"; - const organization = useOrganization(); - const project = useProject(); - return (
{waitpoint.type === "DATETIME" ? ( @@ -227,6 +222,7 @@ function CompleteDateTimeWaitpointForm({ const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); const timeToComplete = waitpoint.completedAfter.getTime() - Date.now(); if (timeToComplete < 0) { @@ -239,7 +235,7 @@ function CompleteDateTimeWaitpointForm({ return ( @@ -292,8 +288,10 @@ function CompleteManualWaitpointForm({ waitpoint }: { waitpoint: { friendlyId: s const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); + const environment = useEnvironment(); + const currentJson = useRef("{\n\n}"); - const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/waitpoints/${waitpoint.friendlyId}/complete`; + const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.friendlyId}/complete`; const submitForm = useCallback( (e: React.FormEvent) => { @@ -382,7 +380,9 @@ export function ForceTimeout({ waitpoint }: { waitpoint: { friendlyId: string } const isLoading = navigation.state !== "idle"; const organization = useOrganization(); const project = useProject(); - const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/waitpoints/${waitpoint.friendlyId}/complete`; + const environment = useEnvironment(); + + const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/${waitpoint.friendlyId}/complete`; return ( diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 6aa61ffdaa..0fcaf0981f 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ActionFunction, json, LoaderFunctionArgs } from "@remix-run/node"; +import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/node"; import { prettyPrintPacket } from "@trigger.dev/core/v3"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; @@ -103,6 +103,11 @@ export const action: ActionFunction = async ({ request, params }) => { friendlyId: runParam, }, include: { + runtimeEnvironment: { + select: { + slug: true, + }, + }, project: { include: { organization: true, @@ -134,6 +139,7 @@ export const action: ActionFunction = async ({ request, params }) => { slug: taskRun.project.organization.slug, }, { slug: taskRun.project.slug }, + { slug: taskRun.runtimeEnvironment.slug }, { friendlyId: newRun.friendlyId }, { spanId: newRun.spanId } ); diff --git a/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts b/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts index 487ee6d1e2..7a9c210934 100644 --- a/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts +++ b/apps/webapp/app/routes/resources.taskruns.bulk.cancel.ts @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ActionFunctionArgs } from "@remix-run/router"; +import { type ActionFunctionArgs } from "@remix-run/router"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -11,6 +11,7 @@ import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.ser const FormSchema = z.object({ organizationSlug: z.string(), projectSlug: z.string(), + environmentSlug: z.string(), failedRedirect: z.string(), runIds: z.array(z.string()).or(z.string()), }); @@ -65,6 +66,7 @@ export async function action({ request }: ActionFunctionArgs) { const path = v3RunsPath( { slug: submission.value.organizationSlug }, { slug: project.slug }, + { slug: submission.value.environmentSlug }, { bulkId: result.friendlyId, } diff --git a/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts b/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts index ee30158c77..77a3df0d6c 100644 --- a/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.bulk.replay.ts @@ -1,5 +1,5 @@ import { parse } from "@conform-to/zod"; -import { ActionFunctionArgs } from "@remix-run/router"; +import { type ActionFunctionArgs } from "@remix-run/router"; import { z } from "zod"; import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; @@ -11,6 +11,7 @@ import { CreateBulkActionService } from "~/v3/services/bulk/createBulkAction.ser const FormSchema = z.object({ organizationSlug: z.string(), projectSlug: z.string(), + environmentSlug: z.string(), failedRedirect: z.string(), runIds: z.array(z.string()).or(z.string()), }); @@ -65,6 +66,7 @@ export async function action({ request }: ActionFunctionArgs) { const path = v3RunsPath( { slug: submission.value.organizationSlug }, { slug: project.slug }, + { slug: submission.value.environmentSlug }, { bulkId: result.friendlyId, } diff --git a/apps/webapp/app/routes/storybook.avatar/route.tsx b/apps/webapp/app/routes/storybook.avatar/route.tsx new file mode 100644 index 0000000000..0f5fed5de8 --- /dev/null +++ b/apps/webapp/app/routes/storybook.avatar/route.tsx @@ -0,0 +1,39 @@ +import { + Avatar, + avatarIcons, + defaultAvatarColors, + type IconAvatar, +} from "~/components/primitives/Avatar"; + +// Map tablerIcons Set to Avatar array with cycling colors +const avatars: IconAvatar[] = Object.entries(avatarIcons).map(([iconName], index) => ({ + type: "icon", + name: iconName, + hex: defaultAvatarColors[index % defaultAvatarColors.length].hex, // Cycle through colors +})); + +export default function Story() { + return ( +
+ {/* Left grid - size-8 */} +
+

Size 8

+
+ {avatars.map((avatar, index) => ( + + ))} +
+
+ + {/* Right grid - size-12 */} +
+

Size 12

+
+ {avatars.map((avatar, index) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook.environment-label/route.tsx b/apps/webapp/app/routes/storybook.environment-label/route.tsx index 656425291e..f0654edd5f 100644 --- a/apps/webapp/app/routes/storybook.environment-label/route.tsx +++ b/apps/webapp/app/routes/storybook.environment-label/route.tsx @@ -1,23 +1,14 @@ import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; -import { Header2 } from "~/components/primitives/Headers"; export default function Story() { return (
- Small (default)
-
- Large - - - - -
); } diff --git a/apps/webapp/app/routes/storybook.input-fields/route.tsx b/apps/webapp/app/routes/storybook.input-fields/route.tsx index 68c2a78f5b..6e5b95fe95 100644 --- a/apps/webapp/app/routes/storybook.input-fields/route.tsx +++ b/apps/webapp/app/routes/storybook.input-fields/route.tsx @@ -55,14 +55,14 @@ function InputFieldSet({ disabled }: { disabled?: boolean }) { disabled={disabled} variant="large" placeholder="Search" - icon={} + icon={} shortcut="⌘K" /> } + icon={} shortcut="⌘K" /> { - const session = await getCurrentProjectSession(request); - return session.get("currentProjectId"); -} - -export async function setCurrentProjectId(id: string, request: Request) { - const session = await getCurrentProjectSession(request); - session.set("currentProjectId", id); - return session; -} - -export async function clearCurrentProjectId(request: Request) { - const session = await getCurrentProjectSession(request); - session.unset("currentProjectId"); - return session; -} diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts new file mode 100644 index 0000000000..3649704b81 --- /dev/null +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { logger } from "./logger.server"; +import { type UserFromSession } from "./session.server"; + +const DashboardPreferences = z.object({ + version: z.literal("1"), + currentProjectId: z.string().optional(), + projects: z.record( + z.string(), + z.object({ + currentEnvironment: z.object({ id: z.string() }), + }) + ), +}); + +export type DashboardPreferences = z.infer; + +export function getDashboardPreferences(data?: any | null): DashboardPreferences { + if (!data) { + return { + version: "1", + projects: {}, + }; + } + + const result = DashboardPreferences.safeParse(data); + if (!result.success) { + logger.error("Failed to parse DashboardPreferences", { data, error: result.error }); + return { + version: "1", + projects: {}, + }; + } + + return result.data; +} + +export async function updateCurrentProjectEnvironmentId({ + user, + projectId, + environmentId, +}: { + user: UserFromSession; + projectId: string; + environmentId: string; +}) { + if (user.isImpersonating) { + return; + } + + //only update if the existing preferences are different + if ( + user.dashboardPreferences.currentProjectId === projectId && + user.dashboardPreferences.projects[projectId]?.currentEnvironment?.id === environmentId + ) { + return; + } + + //ok we need to update the preferences + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + currentProjectId: projectId, + projects: { + ...user.dashboardPreferences.projects, + [projectId]: { + ...user.dashboardPreferences.projects[projectId], + currentEnvironment: { id: environmentId }, + }, + }, + }; + + return prisma.user.update({ + where: { + id: user.id, + }, + data: { + dashboardPreferences: updatedPreferences, + }, + }); +} + +export async function clearCurrentProject({ user }: { user: UserFromSession }) { + if (user.isImpersonating) { + return; + } + + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + currentProjectId: undefined, + }; + + return prisma.user.update({ + where: { + id: user.id, + }, + data: { + dashboardPreferences: updatedPreferences, + }, + }); +} diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts index cb9e94c3b7..0f14fb28b0 100644 --- a/apps/webapp/app/services/email.server.ts +++ b/apps/webapp/app/services/email.server.ts @@ -32,8 +32,10 @@ const alertsClient = singleton( ); function buildTransportOptions(alerts?: boolean): MailTransportOptions { - const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT - logger.debug(`Constructing email transport '${transportType}' for usage '${alerts?'alerts':'general'}'`) + const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT; + logger.debug( + `Constructing email transport '${transportType}' for usage '${alerts ? "alerts" : "general"}'` + ); switch (transportType) { case "aws-ses": @@ -43,8 +45,8 @@ function buildTransportOptions(alerts?: boolean): MailTransportOptions { type: "resend", config: { apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY, - } - } + }, + }; case "smtp": return { type: "smtp", @@ -54,9 +56,9 @@ function buildTransportOptions(alerts?: boolean): MailTransportOptions { secure: alerts ? env.ALERT_SMTP_SECURE : env.SMTP_SECURE, auth: { user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER, - pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD - } - } + pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD, + }, + }, }; default: return { type: undefined }; @@ -87,21 +89,6 @@ export async function sendPlainTextEmail(options: SendPlainTextOptions) { return client.sendPlainText(options); } -export async function scheduleWelcomeEmail(user: User) { - //delay for one minute in development, longer in production - const delay = process.env.NODE_ENV === "development" ? 1000 * 60 : 1000 * 60 * 22; - - await workerQueue.enqueue( - "scheduleEmail", - { - email: "welcome", - to: user.email, - name: user.name ?? undefined, - }, - { runAt: new Date(Date.now() + delay) } - ); -} - export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) { const runAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; await workerQueue.enqueue("scheduleEmail", data, { runAt }); diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts index 3f16857e30..78c771528b 100644 --- a/apps/webapp/app/services/impersonation.server.ts +++ b/apps/webapp/app/services/impersonation.server.ts @@ -1,5 +1,4 @@ -import type { Session } from "@remix-run/node"; -import { createCookieSessionStorage } from "@remix-run/node"; +import { createCookieSessionStorage, type Session } from "@remix-run/node"; import { env } from "~/env.server"; export const impersonationSessionStorage = createCookieSessionStorage({ diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 8240ca2e7f..55cbd06b5a 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -34,11 +34,28 @@ export async function requireUserId(request: Request, redirectTo?: string) { return userId; } +export type UserFromSession = Awaited>; + export async function requireUser(request: Request) { const userId = await requireUserId(request); + const impersonationId = await getImpersonationId(request); const user = await getUserById(userId); - if (user) return user; + if (user) { + return { + id: user.id, + email: user.email, + name: user.name, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + admin: user.admin, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + dashboardPreferences: user.dashboardPreferences, + confirmedBasicDetails: user.confirmedBasicDetails, + isImpersonating: !!impersonationId, + }; + } throw await logout(request); } diff --git a/apps/webapp/app/utils/cn.ts b/apps/webapp/app/utils/cn.ts index 0c2e8411fa..dc64e862cf 100644 --- a/apps/webapp/app/utils/cn.ts +++ b/apps/webapp/app/utils/cn.ts @@ -9,6 +9,7 @@ const customTwMerge = extendTailwindMerge({ "xxs", "xs", "sm", + "2sm", "md", "lg", "xl", diff --git a/apps/webapp/app/utils/environmentSort.ts b/apps/webapp/app/utils/environmentSort.ts index 64a681c1fc..4ed749bf64 100644 --- a/apps/webapp/app/utils/environmentSort.ts +++ b/apps/webapp/app/utils/environmentSort.ts @@ -1,4 +1,4 @@ -import { Prisma, RuntimeEnvironmentType } from "@trigger.dev/database"; +import { type RuntimeEnvironmentType } from "@trigger.dev/database"; const environmentSortOrder: RuntimeEnvironmentType[] = [ "DEVELOPMENT", diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index d1d2e177d8..332ed0aa0f 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -1,12 +1,13 @@ -import type { TaskRun, WorkerDeployment } from "@trigger.dev/database"; +import type { RuntimeEnvironment, TaskRun, WorkerDeployment } from "@trigger.dev/database"; import { z } from "zod"; -import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import type { Organization } from "~/models/organization.server"; import type { Project } from "~/models/project.server"; import { objectToSearchParams } from "./searchParams"; export type OrgForPath = Pick; export type ProjectForPath = Pick; +export type EnvironmentForPath = Pick; export type v3RunForPath = Pick; export type v3SpanForPath = Pick; export type DeploymentForPath = Pick; @@ -22,12 +23,16 @@ export const ProjectParamSchema = OrganizationParamsSchema.extend({ projectParam: z.string(), }); +export const EnvironmentParamSchema = ProjectParamSchema.extend({ + envParam: z.string(), +}); + //v3 -export const v3TaskParamsSchema = ProjectParamSchema.extend({ +export const v3TaskParamsSchema = EnvironmentParamSchema.extend({ taskParam: z.string(), }); -export const v3RunParamsSchema = ProjectParamSchema.extend({ +export const v3RunParamsSchema = EnvironmentParamSchema.extend({ runParam: z.string(), }); @@ -35,11 +40,11 @@ export const v3SpanParamsSchema = v3RunParamsSchema.extend({ spanParam: z.string(), }); -export const v3DeploymentParams = ProjectParamSchema.extend({ +export const v3DeploymentParams = EnvironmentParamSchema.extend({ deploymentParam: z.string(), }); -export const v3ScheduleParams = ProjectParamSchema.extend({ +export const v3ScheduleParams = EnvironmentParamSchema.extend({ scheduleParam: z.string(), }); @@ -93,7 +98,7 @@ export function selectPlanPath(organization: OrgForPath) { } export function organizationTeamPath(organization: OrgForPath) { - return `${organizationPath(organization)}/team`; + return `${organizationPath(organization)}/settings/team`; } export function inviteTeamMemberPath(organization: OrgForPath) { @@ -123,79 +128,126 @@ function projectParam(project: ProjectForPath) { return project.slug; } +function environmentParam(environment: EnvironmentForPath) { + return environment.slug; +} + //v3 project export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) { - return `/orgs/${organizationParam(organization)}/projects/v3/${projectParam(project)}`; + return `/orgs/${organizationParam(organization)}/projects/${projectParam(project)}`; } -export function v3TasksStreamingPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/tasks/stream`; +export function v3EnvironmentPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `/orgs/${organizationParam(organization)}/projects/${projectParam( + project + )}/env/${environmentParam(environment)}`; } -export function v3ApiKeysPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/apikeys`; +export function v3TasksStreamingPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/tasks/stream`; +} + +export function v3ApiKeysPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/apikeys`; } -export function v3EnvironmentVariablesPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/environment-variables`; +export function v3EnvironmentVariablesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/environment-variables`; } -export function v3ConcurrencyPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/concurrency`; +export function v3ConcurrencyPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; } -export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3EnvironmentVariablesPath(organization, project)}/new`; +export function v3NewEnvironmentVariablesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentVariablesPath(organization, project, environment)}/new`; } -export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/alerts`; +export function v3ProjectAlertsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/alerts`; } -export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectAlertsPath(organization, project)}/new`; +export function v3NewProjectAlertPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3ProjectAlertsPath(organization, project, environment)}/new`; } export function v3NewProjectAlertPathConnectToSlackPath( organization: OrgForPath, - project: ProjectForPath + project: ProjectForPath, + environment: EnvironmentForPath ) { - return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`; + return `${v3ProjectAlertsPath(organization, project, environment)}/new/connect-to-slack`; } export function v3TestPath( organization: OrgForPath, project: ProjectForPath, - environmentSlug?: string + environment: EnvironmentForPath ) { - return `${v3ProjectPath(organization, project)}/test${ - environmentSlug ? `?environment=${environmentSlug}` : "" - }`; + return `${v3EnvironmentPath(organization, project, environment)}/test`; } export function v3TestTaskPath( organization: OrgForPath, project: ProjectForPath, - task: TaskForPath, - environmentSlug: string + environment: EnvironmentForPath, + task: TaskForPath ) { - return `${v3TestPath(organization, project)}/tasks/${encodeURIComponent( + return `${v3TestPath(organization, project, environment)}/tasks/${encodeURIComponent( task.taskIdentifier - )}?environment=${environmentSlug}`; + )}`; } export function v3RunsPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, filters?: TaskRunListSearchFilters ) { const searchParams = objectToSearchParams(filters); const query = searchParams ? `?${searchParams.toString()}` : ""; - return `${v3ProjectPath(organization, project)}/runs${query}`; + return `${v3EnvironmentPath(organization, project, environment)}/runs${query}`; } -export function v3RunPath(organization: OrgForPath, project: ProjectForPath, run: v3RunForPath) { - return `${v3RunsPath(organization, project)}/${run.friendlyId}`; +export function v3RunPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + run: v3RunForPath +) { + return `${v3RunsPath(organization, project, environment)}/${run.friendlyId}`; } export function v3RunDownloadLogsPath(run: v3RunForPath) { @@ -205,107 +257,125 @@ export function v3RunDownloadLogsPath(run: v3RunForPath) { export function v3RunSpanPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, run: v3RunForPath, span: v3SpanForPath ) { - return `${v3RunPath(organization, project, run)}?span=${span.spanId}`; + return `${v3RunPath(organization, project, environment, run)}?span=${span.spanId}`; } export function v3RunStreamingPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, run: v3RunForPath ) { - return `${v3RunPath(organization, project, run)}/stream`; + return `${v3RunPath(organization, project, environment, run)}/stream`; } -export function v3SchedulesPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/schedules`; +export function v3SchedulesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/schedules`; } export function v3SchedulePath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, schedule: { friendlyId: string } ) { - return `${v3ProjectPath(organization, project)}/schedules/${schedule.friendlyId}`; + return `${v3EnvironmentPath(organization, project, environment)}/schedules/${ + schedule.friendlyId + }`; } export function v3EditSchedulePath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, schedule: { friendlyId: string } ) { - return `${v3ProjectPath(organization, project)}/schedules/edit/${schedule.friendlyId}`; + return `${v3EnvironmentPath(organization, project, environment)}/schedules/edit/${ + schedule.friendlyId + }`; } -export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/schedules/new`; +export function v3NewSchedulePath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/schedules/new`; } -export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/batches`; +export function v3BatchesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/batches`; } export function v3BatchPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, batch: { friendlyId: string } ) { - return `${v3ProjectPath(organization, project)}/batches?id=${batch.friendlyId}`; + return `${v3EnvironmentPath(organization, project, environment)}/batches?id=${batch.friendlyId}`; } export function v3BatchRunsPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, batch: { friendlyId: string } ) { - return `${v3ProjectPath(organization, project)}/runs?batchId=${batch.friendlyId}`; + return `${v3RunsPath(organization, project, environment, { batchId: batch.friendlyId })}`; } -export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/settings`; +export function v3ProjectSettingsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/settings`; } -export function v3DeploymentsPath(organization: OrgForPath, project: ProjectForPath) { - return `${v3ProjectPath(organization, project)}/deployments`; +export function v3DeploymentsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/deployments`; } export function v3DeploymentPath( organization: OrgForPath, project: ProjectForPath, + environment: EnvironmentForPath, deployment: DeploymentForPath, currentPage: number ) { const query = currentPage ? `?page=${currentPage}` : ""; - return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}${query}`; + return `${v3DeploymentsPath(organization, project, environment)}/${deployment.shortCode}${query}`; } -export function v3BillingPath(organization: OrgForPath) { - return `${organizationPath(organization)}/v3/billing`; +export function v3BillingPath(organization: OrgForPath, message?: string) { + return `${organizationPath(organization)}/settings/billing${ + message ? `?message=${encodeURIComponent(message)}` : "" + }`; } export function v3StripePortalPath(organization: OrgForPath) { - return `/resources/${organization.slug}/subscription/v3/portal`; + return `/resources/${organization.slug}/subscription/portal`; } export function v3UsagePath(organization: OrgForPath) { - return `${organizationPath(organization)}/v3/usage`; -} - -// Task -export function runTaskPath(runPath: string, taskId: string) { - return `${runPath}/tasks/${taskId}`; -} - -// Event -export function runTriggerPath(runPath: string) { - return `${runPath}/trigger`; -} - -// Event -export function runCompletedPath(runPath: string) { - return `${runPath}/completed`; + return `${organizationPath(organization)}/settings/usage`; } // Docs @@ -320,16 +390,3 @@ export function docsPath(path: string) { export function docsTroubleshootingPath(path: string) { return `${docsRoot()}/v3/troubleshooting`; } - -export function docsIntegrationPath(api: string) { - return `${docsRoot()}/integrations/apis/${api}`; -} - -export function docsCreateIntegration() { - return `${docsRoot()}/integrations/create`; -} - -//api -export function apiReferencePath(apiSlug: string) { - return `https://trigger.dev/apis/${apiSlug}`; -} diff --git a/apps/webapp/app/utils/sse.ts b/apps/webapp/app/utils/sse.ts index ef6135a866..9f3452cf93 100644 --- a/apps/webapp/app/utils/sse.ts +++ b/apps/webapp/app/utils/sse.ts @@ -1,8 +1,9 @@ -import { LoaderFunctionArgs } from "@remix-run/node"; +import { type LoaderFunctionArgs } from "@remix-run/node"; +import { type Params } from "@remix-run/router"; import { eventStream } from "remix-utils/sse/server"; import { setInterval } from "timers/promises"; -type SendFunction = Parameters[1]>[0]; +export type SendFunction = Parameters[1]>[0]; type HandlerParams = { send: SendFunction; @@ -15,12 +16,13 @@ type SSEHandlers = { initStream?: (params: HandlerParams) => Promise | boolean | void; /** Return false to stop */ iterator?: (params: HandlerParams & { date: Date }) => Promise | boolean | void; - cleanup?: () => void; + cleanup?: (params: HandlerParams) => void; }; type SSEContext = { id: string; request: Request; + params: Params; controller: AbortController; debug: (message: string) => void; }; @@ -38,19 +40,23 @@ const connections: Set = new Set(); export function createSSELoader(options: SSEOptions) { const { timeout, interval = 500, debug = false, handler } = options; - return async function loader({ request }: LoaderFunctionArgs) { + return async function loader({ request, params }: LoaderFunctionArgs) { const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8); const internalController = new AbortController(); const timeoutSignal = AbortSignal.timeout(timeout); const log = (message: string) => { - if (debug) console.log(`SSE: [${id}] ${message} (${connections.size} open connections)`); + if (debug) + console.log( + `SSE: [${request.url} ${id}] ${message} (${connections.size} open connections)` + ); }; const context: SSEContext = { id, request, + params, controller: internalController, debug: log, }; @@ -167,7 +173,7 @@ export function createSSELoader(options: SSEOptions) { log("Cleanup called"); if (handlers.cleanup) { try { - handlers.cleanup(); + handlers.cleanup({ send }); } catch (error) { log( `Error in cleanup handler: ${ diff --git a/apps/webapp/app/utils/tablerIcons.ts b/apps/webapp/app/utils/tablerIcons.ts index 559f08356a..e188e779bf 100644 --- a/apps/webapp/app/utils/tablerIcons.ts +++ b/apps/webapp/app/utils/tablerIcons.ts @@ -4820,3 +4820,5 @@ const tablerIconNames = [ ]; export const tablerIcons = new Set(tablerIconNames); + +export const tablerIconsFilled = new Set(tablerIconNames.filter((i) => i.endsWith("-filled"))); diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index c373b11f8a..5eff1a6c54 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -1,27 +1,27 @@ import { - ChatPostMessageArguments, + type ChatPostMessageArguments, ErrorCode, - WebAPIHTTPError, - WebAPIPlatformError, - WebAPIRateLimitedError, - WebAPIRequestError, + type WebAPIHTTPError, + type WebAPIPlatformError, + type WebAPIRateLimitedError, + type WebAPIRequestError, } from "@slack/web-api"; import { Webhook, TaskRunError, createJsonErrorObject, - RunFailedWebhook, - DeploymentFailedWebhook, - DeploymentSuccessWebhook, + type RunFailedWebhook, + type DeploymentFailedWebhook, + type DeploymentSuccessWebhook, isOOMRunError, } from "@trigger.dev/core/v3"; import assertNever from "assert-never"; import { subtle } from "crypto"; -import { Prisma, prisma, PrismaClientOrTransaction } from "~/db.server"; +import { type Prisma, type prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { OrgIntegrationRepository, - OrganizationIntegrationForService, + type OrganizationIntegrationForService, } from "~/models/orgIntegration.server"; import { ProjectAlertEmailProperties, @@ -37,7 +37,7 @@ import { commonWorker } from "~/v3/commonWorker.server"; import { FINAL_ATTEMPT_STATUSES } from "~/v3/taskStatus"; import { BaseService } from "../baseService.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; -import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database"; +import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database"; import { alertsRateLimiter } from "~/v3/alertsRateLimiter.server"; import { v3RunPath } from "~/utils/pathBuilder"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; @@ -380,6 +380,7 @@ export class DeliverAlertService extends BaseService { dashboardUrl: `${env.APP_ORIGIN}${v3RunPath( alert.project.organization, alert.project, + alert.environment, alert.taskRun )}`, }, diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index a959d13008..eff6dcd3ad 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -4,6 +4,7 @@ import { BaseService, ServiceValidationError } from "./baseService.server"; import { getLimit } from "~/services/platform.v3.server"; import { getTimezones } from "~/utils/timezones.server"; import { env } from "~/env.server"; +import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database"; type Schedule = { cron: string; @@ -69,6 +70,12 @@ export class CheckScheduleService extends BaseService { }, select: { organizationId: true, + environments: { + select: { + id: true, + type: true, + }, + }, }, }); @@ -77,10 +84,9 @@ export class CheckScheduleService extends BaseService { } const limit = await getLimit(project.organizationId, "schedules", 100_000_000); - const schedulesCount = await this._prisma.taskSchedule.count({ - where: { - projectId, - }, + const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({ + prisma: this._prisma, + environments: project.environments, }); if (schedulesCount >= limit) { @@ -90,4 +96,27 @@ export class CheckScheduleService extends BaseService { } } } + + static async getUsedSchedulesCount({ + prisma, + environments, + }: { + prisma: PrismaClientOrTransaction; + environments: { id: string; type: RuntimeEnvironmentType }[]; + }) { + const deployedEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT"); + const schedulesCount = await prisma.taskScheduleInstance.count({ + where: { + environmentId: { + in: deployedEnvironments.map((env) => env.id), + }, + active: true, + taskSchedule: { + active: true, + }, + }, + }); + + return schedulesCount; + } } diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 70aa09c2b0..4e62f9cc61 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -1,9 +1,9 @@ -import { Prisma, TaskSchedule } from "@trigger.dev/database"; +import { type Prisma, type TaskSchedule } from "@trigger.dev/database"; import cronstrue from "cronstrue"; import { nanoid } from "nanoid"; import { $transaction } from "~/db.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; -import { UpsertSchedule } from "../schedules"; +import { type UpsertSchedule } from "../schedules"; import { calculateNextScheduledTimestamp } from "../utils/calculateNextSchedule.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { CheckScheduleService } from "./checkSchedule.server"; diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index af13215ecc..43200351bc 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -65,6 +65,7 @@ const charcoal = { 650: "#2C3034", 700: "#272A2E", 750: "#212327", + 775: "#1C1E21", 800: "#1A1B1F", 850: "#15171A", 900: "#121317", diff --git a/internal-packages/database/prisma/migrations/20250310132020_added_dashboard_preferences_to_user/migration.sql b/internal-packages/database/prisma/migrations/20250310132020_added_dashboard_preferences_to_user/migration.sql new file mode 100644 index 0000000000..636a37b2a2 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250310132020_added_dashboard_preferences_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "dashboardPreferences" JSONB; diff --git a/internal-packages/database/prisma/migrations/20250313114927_organization_added_avatar/migration.sql b/internal-packages/database/prisma/migrations/20250313114927_organization_added_avatar/migration.sql new file mode 100644 index 0000000000..f1dcc4e07c --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250313114927_organization_added_avatar/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "avatar" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index af2c88485d..0398103546 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -23,13 +23,19 @@ model User { name String? avatarUrl String? - admin Boolean @default(false) - isOnCloudWaitlist Boolean @default(false) + admin Boolean @default(false) + + /// Preferences for the dashboard + dashboardPreferences Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// @deprecated + isOnCloudWaitlist Boolean @default(false) + /// @deprecated featureCloud Boolean @default(false) + /// @deprecated isOnHostedRepoWaitlist Boolean @default(false) marketingEmails Boolean @default(true) @@ -39,7 +45,9 @@ model User { orgMemberships OrgMember[] sentInvites OrgMemberInvite[] - apiVotes ApiIntegrationVote[] + + /// @deprecated + apiVotes ApiIntegrationVote[] invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id]) invitationCodeId String? @@ -123,11 +131,17 @@ model Organization { companySize String? + avatar Json? + runsEnabled Boolean @default(true) - v3Enabled Boolean @default(false) + v3Enabled Boolean @default(false) + + /// @deprecated v2Enabled Boolean @default(false) + /// @deprecated v2MarqsEnabled Boolean @default(false) + /// @deprecated hasRequestedV3 Boolean @default(false) environments RuntimeEnvironment[] diff --git a/internal-packages/emails/src/index.tsx b/internal-packages/emails/src/index.tsx index 189849d5c8..16e1da7ccb 100644 --- a/internal-packages/emails/src/index.tsx +++ b/internal-packages/emails/src/index.tsx @@ -19,10 +19,6 @@ export { type MailTransportOptions }; export const DeliverEmailSchema = z .discriminatedUnion("email", [ - z.object({ - email: z.literal("welcome"), - name: z.string().optional(), - }), z.object({ email: z.literal("magic_link"), magicLink: z.string().url(), @@ -86,11 +82,6 @@ export class EmailClient { component: ReactElement; } { switch (data.email) { - case "welcome": - return { - subject: "✨ Welcome to Trigger.dev!", - component: , - }; case "magic_link": return { subject: "Magic sign-in link for Trigger.dev", diff --git a/internal-packages/run-engine/src/engine/index.ts b/internal-packages/run-engine/src/engine/index.ts index 87d3b7e84d..834652fad6 100644 --- a/internal-packages/run-engine/src/engine/index.ts +++ b/internal-packages/run-engine/src/engine/index.ts @@ -1652,6 +1652,12 @@ export class RunEngine { return this.runQueue.lengthOfEnvQueue(environment); } + async currentConcurrencyOfEnvQueue( + environment: MinimalAuthenticatedEnvironment + ): Promise { + return this.runQueue.currentConcurrencyOfEnvironment(environment); + } + /** * This creates a DATETIME waitpoint, that will be completed automatically when the specified date is reached. * If you pass an `idempotencyKey`, the waitpoint will be created only if it doesn't already exist. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c79c6bf79..a9f058da79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,31 +144,6 @@ importers: specifier: ^4.7.0 version: 4.7.1 - apps/proxy: - dependencies: - '@aws-sdk/client-sqs': - specifier: ^3.445.0 - version: 3.454.0 - '@trigger.dev/core': - specifier: workspace:* - version: link:../../packages/core - ulidx: - specifier: ^2.2.1 - version: 2.2.1 - zod: - specifier: 3.23.8 - version: 3.23.8 - zod-error: - specifier: 1.5.0 - version: 1.5.0 - devDependencies: - '@cloudflare/workers-types': - specifier: ^4.20240512.0 - version: 4.20240512.0 - wrangler: - specifier: ^3.57.1 - version: 3.57.1(@cloudflare/workers-types@4.20240512.0) - apps/supervisor: dependencies: '@kubernetes/client-node': @@ -5533,13 +5508,6 @@ packages: sisteransi: 1.0.5 dev: false - /@cloudflare/kv-asset-handler@0.3.2: - resolution: {integrity: sha512-EeEjMobfuJrwoctj7FA1y1KEbM0+Q1xSjobIEyie9k4haVEBB7vkDvsasw1pM3rO39mL2akxIAzLMUAtrMHZhA==} - engines: {node: '>=16.13'} - dependencies: - mime: 3.0.0 - dev: true - /@cloudflare/kv-asset-handler@0.3.4: resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} @@ -5547,15 +5515,6 @@ packages: mime: 3.0.0 dev: false - /@cloudflare/workerd-darwin-64@1.20240512.0: - resolution: {integrity: sha512-VMp+CsSHFALQiBzPdQ5dDI4T1qwLu0mQ0aeKVNDosXjueN0f3zj/lf+mFil5/9jBbG3t4mG0y+6MMnalP9Lobw==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - /@cloudflare/workerd-darwin-64@1.20240806.0: resolution: {integrity: sha512-FqcVBBCO//I39K5F+HqE/v+UkqY1UrRnS653Jv+XsNNH9TpX5fTs7VCKG4kDSnmxlAaKttyIN5sMEt7lpuNExQ==} engines: {node: '>=16'} @@ -5565,15 +5524,6 @@ packages: dev: false optional: true - /@cloudflare/workerd-darwin-arm64@1.20240512.0: - resolution: {integrity: sha512-lZktXGmzMrB5rJqY9+PmnNfv1HKlj/YLZwMjPfF0WVKHUFdvQbAHsi7NlKv6mW9uIvlZnS+K4sIkWc0MDXcRnA==} - engines: {node: '>=16'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - /@cloudflare/workerd-darwin-arm64@1.20240806.0: resolution: {integrity: sha512-8c3KvmzYp/wg+82KHSOzDetJK+pThH4MTrU1OsjmsR2cUfedm5dk5Lah9/0Ld68+6A0umFACi4W2xJHs/RoBpA==} engines: {node: '>=16'} @@ -5583,15 +5533,6 @@ packages: dev: false optional: true - /@cloudflare/workerd-linux-64@1.20240512.0: - resolution: {integrity: sha512-wrHvqCZZqXz6Y3MUTn/9pQNsvaoNjbJpuA6vcXsXu8iCzJi911iVW2WUEBX+MpUWD+mBIP0oXni5tTlhkokOPw==} - engines: {node: '>=16'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - /@cloudflare/workerd-linux-64@1.20240806.0: resolution: {integrity: sha512-/149Bpxw4e2p5QqnBc06g0mx+4sZYh9j0doilnt0wk/uqYkLp0DdXGMQVRB74sBLg2UD3wW8amn1w3KyFhK2tQ==} engines: {node: '>=16'} @@ -5601,15 +5542,6 @@ packages: dev: false optional: true - /@cloudflare/workerd-linux-arm64@1.20240512.0: - resolution: {integrity: sha512-YPezHMySL9J9tFdzxz390eBswQ//QJNYcZolz9Dgvb3FEfdpK345cE/bsWbMOqw5ws2f82l388epoenghtYvAg==} - engines: {node: '>=16'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - /@cloudflare/workerd-linux-arm64@1.20240806.0: resolution: {integrity: sha512-lacDWY3S1rKL/xT6iMtTQJEKmTTKrBavPczInEuBFXElmrS6IwVjZwv8hhVm32piyNt/AuFu9BYoJALi9D85/g==} engines: {node: '>=16'} @@ -5619,15 +5551,6 @@ packages: dev: false optional: true - /@cloudflare/workerd-windows-64@1.20240512.0: - resolution: {integrity: sha512-SxKapDrIYSscMR7lGIp/av0l6vokjH4xQ9ACxHgXh+OdOus9azppSmjaPyw4/ePvg7yqpkaNjf9o258IxWtvKQ==} - engines: {node: '>=16'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - /@cloudflare/workerd-windows-64@1.20240806.0: resolution: {integrity: sha512-hC6JEfTSQK6//Lg+D54TLVn1ceTPY+fv4MXqDZIYlPP53iN+dL8Xd0utn2SG57UYdlL5FRAhm/EWHcATZg1RgA==} engines: {node: '>=16'} @@ -5641,10 +5564,6 @@ packages: resolution: {integrity: sha512-SyD4iw6jM4anZaG+ujgVETV4fulF2KHBOW31eavbVN7TNpk2l4aJgwY1YSPK00IKSWsoQuH2TigR446KuT5lqQ==} dev: false - /@cloudflare/workers-types@4.20240512.0: - resolution: {integrity: sha512-o2yTEWg+YK/I1t/Me+dA0oarO0aCbjibp6wSeaw52DSE9tDyKJ7S+Qdyw/XsMrKn4t8kF6f/YOba+9O4MJfW9w==} - dev: true - /@codemirror/autocomplete@6.4.0(@codemirror/language@6.3.2)(@codemirror/state@6.2.0)(@codemirror/view@6.7.2)(@lezer/common@1.0.2): resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==} peerDependencies: @@ -6016,6 +5935,7 @@ packages: esbuild: '*' dependencies: esbuild: 0.17.19 + dev: false /@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19): resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} @@ -6025,6 +5945,7 @@ packages: esbuild: 0.17.19 escape-string-regexp: 4.0.0 rollup-plugin-node-polyfills: 0.2.1 + dev: false /@esbuild/aix-ppc64@0.19.11: resolution: {integrity: sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==} @@ -6066,6 +5987,7 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/android-arm64@0.17.6: @@ -6135,6 +6057,7 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/android-arm@0.17.6: @@ -6195,6 +6118,7 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: false optional: true /@esbuild/android-x64@0.17.6: @@ -6255,6 +6179,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false optional: true /@esbuild/darwin-arm64@0.17.6: @@ -6315,6 +6240,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false optional: true /@esbuild/darwin-x64@0.17.6: @@ -6375,6 +6301,7 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: false optional: true /@esbuild/freebsd-arm64@0.17.6: @@ -6435,6 +6362,7 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: false optional: true /@esbuild/freebsd-x64@0.17.6: @@ -6495,6 +6423,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-arm64@0.17.6: @@ -6555,6 +6484,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-arm@0.17.6: @@ -6615,6 +6545,7 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-ia32@0.17.6: @@ -6684,6 +6615,7 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-loong64@0.17.6: @@ -6744,6 +6676,7 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-mips64el@0.17.6: @@ -6804,6 +6737,7 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-ppc64@0.17.6: @@ -6864,6 +6798,7 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-riscv64@0.17.6: @@ -6924,6 +6859,7 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-s390x@0.17.6: @@ -6984,6 +6920,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@esbuild/linux-x64@0.17.6: @@ -7044,6 +6981,7 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: false optional: true /@esbuild/netbsd-x64@0.17.6: @@ -7112,6 +7050,7 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: false optional: true /@esbuild/openbsd-x64@0.17.6: @@ -7172,6 +7111,7 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: false optional: true /@esbuild/sunos-x64@0.17.6: @@ -7232,6 +7172,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false optional: true /@esbuild/win32-arm64@0.17.6: @@ -7292,6 +7233,7 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false optional: true /@esbuild/win32-ia32@0.17.6: @@ -7352,6 +7294,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false optional: true /@esbuild/win32-x64@0.17.6: @@ -17189,6 +17132,7 @@ packages: resolution: {integrity: sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==} dependencies: '@types/node': 18.19.20 + dev: false /@types/node@12.20.55: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -18806,6 +18750,7 @@ packages: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} dependencies: printable-characters: 1.0.42 + dev: false /asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -19196,6 +19141,7 @@ packages: /blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + dev: false /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} @@ -19483,6 +19429,7 @@ packages: tslib: 2.6.2 transitivePeerDependencies: - supports-color + dev: false /case-anything@2.1.13: resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} @@ -20445,6 +20392,7 @@ packages: /data-uri-to-buffer@2.0.2: resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + dev: false /data-uri-to-buffer@3.0.1: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} @@ -21543,6 +21491,7 @@ packages: '@esbuild/win32-arm64': 0.17.19 '@esbuild/win32-ia32': 0.17.19 '@esbuild/win32-x64': 0.17.19 + dev: false /esbuild@0.17.6: resolution: {integrity: sha512-TKFRp9TxrJDdRWfSsSERKEovm6v30iHnrjlcGhLBOtReE28Yp1VSBRfO3GTaOFMoxsNerx4TjrhzSuma9ha83Q==} @@ -22297,6 +22246,7 @@ packages: /estree-walker@0.6.1: resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + dev: false /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -23018,6 +22968,7 @@ packages: dependencies: data-uri-to-buffer: 2.0.2 source-map: 0.6.1 + dev: false /get-stream@4.1.0: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} @@ -24820,6 +24771,7 @@ packages: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: sourcemap-codec: 1.4.8 + dev: false /magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} @@ -25432,6 +25384,7 @@ packages: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true + dev: false /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -25455,29 +25408,6 @@ packages: hasBin: true dev: true - /miniflare@3.20240512.0: - resolution: {integrity: sha512-X0PlKR0AROKpxFoJNmRtCMIuJxj+ngEcyTOlEokj2rAQ0TBwUhB4/1uiPvdI6ofW5NugPOD1uomAv+gLjwsLDQ==} - engines: {node: '>=16.13'} - hasBin: true - dependencies: - '@cspotcode/source-map-support': 0.8.1 - acorn: 8.12.1 - acorn-walk: 8.3.2 - capnp-ts: 0.7.0 - exit-hook: 2.2.1 - glob-to-regexp: 0.4.1 - stoppable: 1.1.0 - undici: 5.28.4 - workerd: 1.20240512.0 - ws: 8.18.0 - youch: 3.3.3 - zod: 3.23.8 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: true - /miniflare@3.20240806.0: resolution: {integrity: sha512-jDsXBJOLUVpIQXHsluX3xV0piDxXolTCsxdje2Ex2LTC9PsSoBIkMwvCmnCxe9wpJJCq8rb0UMyeEn3KOF3LOw==} engines: {node: '>=16.13'} @@ -25760,6 +25690,7 @@ packages: /mustache@4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + dev: false /mute-stream@1.0.0: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} @@ -26055,6 +25986,7 @@ packages: /node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + dev: false /node-gyp@10.2.0: resolution: {integrity: sha512-sp3FonBAaFe4aYTcFdZUn2NYkbP7xroPGYvQmP4Nl5PxamznItBnNCgjrVTKrEfQynInMsJvZrdmqUnysCJ8rw==} @@ -26917,6 +26849,7 @@ packages: /path-to-regexp@6.2.1: resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} + dev: false /path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} @@ -27635,6 +27568,7 @@ packages: /printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + dev: false /prism-react-renderer@2.1.0(react@18.3.1): resolution: {integrity: sha512-I5cvXHjA1PVGbGm1MsWCpvBCRrYyxEri0MC7/JbfIfYfcXAxHyO5PaUjs3A8H5GW6kJcLhTHxxMaOZZpRZD2iQ==} @@ -29157,16 +29091,19 @@ packages: estree-walker: 0.6.1 magic-string: 0.25.9 rollup-pluginutils: 2.8.2 + dev: false /rollup-plugin-node-polyfills@0.2.1: resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} dependencies: rollup-plugin-inject: 3.0.2 + dev: false /rollup-pluginutils@2.8.2: resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} dependencies: estree-walker: 0.6.1 + dev: false /rollup@3.10.0: resolution: {integrity: sha512-JmRYz44NjC1MjVF2VKxc0M1a97vn+cDxeqWmnwyAF4FvpjK8YFdHpaqvQB+3IxCvX05vJxKZkoMDU8TShhmJVA==} @@ -29346,6 +29283,7 @@ packages: dependencies: '@types/node-forge': 1.3.10 node-forge: 1.3.1 + dev: false /sembear@0.5.2: resolution: {integrity: sha512-Ij1vCAdFgWABd7zTg50Xw1/p0JgESNxuLlneEAsmBrKishA06ulTTL/SHGmNy2Zud7+rKrHTKNI6moJsn1ppAQ==} @@ -29768,6 +29706,7 @@ packages: /sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead + dev: false /space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -29917,6 +29856,7 @@ packages: dependencies: as-table: 1.0.55 get-source: 2.0.12 + dev: false /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -29936,6 +29876,7 @@ packages: /stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + dev: false /stream-buffers@3.0.2: resolution: {integrity: sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==} @@ -32827,19 +32768,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /workerd@1.20240512.0: - resolution: {integrity: sha512-VUBmR1PscAPHEE0OF/G2K7/H1gnr9aDWWZzdkIgWfNKkv8dKFCT75H+GJtUHjfwqz3rYCzaNZmatSXOpLGpF8A==} - engines: {node: '>=16'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20240512.0 - '@cloudflare/workerd-darwin-arm64': 1.20240512.0 - '@cloudflare/workerd-linux-64': 1.20240512.0 - '@cloudflare/workerd-linux-arm64': 1.20240512.0 - '@cloudflare/workerd-windows-64': 1.20240512.0 - dev: true - /workerd@1.20240806.0: resolution: {integrity: sha512-yyNtyzTMgVY0sgYijHBONqZFVXsOFGj2jDjS8MF/RbO2ZdGROvs4Hkc/9QnmqFWahE0STxXeJ1yW1yVotdF0UQ==} engines: {node: '>=16'} @@ -32853,39 +32781,6 @@ packages: '@cloudflare/workerd-windows-64': 1.20240806.0 dev: false - /wrangler@3.57.1(@cloudflare/workers-types@4.20240512.0): - resolution: {integrity: sha512-M8YnWUwdrb8AFiRePtVnzlDn02OX4osWvdl8oVh6eyZqqkqXYg7lwlYBr14Qj92pMN4JvMBmDZoukkYHvwpJRg==} - engines: {node: '>=16.17.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20240512.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - dependencies: - '@cloudflare/kv-asset-handler': 0.3.2 - '@cloudflare/workers-types': 4.20240512.0 - '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) - '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) - blake3-wasm: 2.1.5 - chokidar: 3.6.0 - esbuild: 0.17.19 - miniflare: 3.20240512.0 - nanoid: 3.3.7 - path-to-regexp: 6.2.1 - resolve: 1.22.8 - resolve.exports: 2.0.2 - selfsigned: 2.4.1 - source-map: 0.6.1 - xxhash-wasm: 1.0.2 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - dev: true - /wrangler@3.70.0: resolution: {integrity: sha512-aMtCEXmH02SIxbxOFGGuJ8ZemmG9W+IcNRh5D4qIKgzSxqy0mt9mRoPNPSv1geGB2/8YAyeLGPf+tB4lxz+ssg==} engines: {node: '>=16.17.0'} @@ -33046,6 +32941,7 @@ packages: /xxhash-wasm@1.0.2: resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} + dev: false /y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -33160,6 +33056,7 @@ packages: cookie: 0.5.0 mustache: 4.2.0 stacktracey: 2.1.8 + dev: false /yt-dlp-wrap@2.3.12: resolution: {integrity: sha512-P8fJ+6M1YjukyJENCTviNLiZ8mokxprR54ho3DsSKPWDcac489OjRiStGEARJr6un6ETS6goTn4CWl/b/rM3aA==} diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 6ca20da4bf..e3e3a1c9a4 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -1,4 +1,4 @@ -import { logger, task, timeout, wait } from "@trigger.dev/sdk"; +import { batch, logger, task, timeout, wait } from "@trigger.dev/sdk"; import { setTimeout } from "timers/promises"; export const helloWorldTask = task({