diff --git a/apps/proxy/.dev.vars.example b/apps/proxy/.dev.vars.example deleted file mode 100644 index 76de44a1914..00000000000 --- 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 64ab2601f9b..00000000000 --- 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 3b0fe33c47f..00000000000 --- 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 89c93d85a8e..00000000000 --- 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 6544c5fee14..00000000000 --- 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 f3010f2af76..00000000000 --- 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 80646e60a09..00000000000 --- 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 cb6c9c2344b..00000000000 --- 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 d3b2dcce543..00000000000 --- 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 412db29acdf..00000000000 --- 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 e68643d88ef..00000000000 --- 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 26d7d3b00d3..00000000000 --- 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 c8c2aca7bf7..00000000000 --- 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 ccbd7b4338f..00000000000 --- 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 41433234310..00000000000 --- 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 b35efe30732..00000000000 --- 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 3cbfb66cd8a..00000000000 --- 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 00000000000..74f1ee5458a --- /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 00000000000..4a869ec8f62 --- /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 00000000000..d8d8a7b66bc --- /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 00000000000..be703888772 --- /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 00000000000..7bcb261c4dd --- /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 00000000000..cd6b4e37fe1 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 00000000000..dff3ecc106a 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 00000000000..af76136e82d 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 00000000000..b0b49fa2cbf --- /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 00000000000..bd69bbe21e4 --- /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 c6544f6496b..1a8f4b2ad94 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 47e94c3ba3c..fc376480a61 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 f761e23fa92..2dfcce96345 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 adc5ba32412..3aa3378d0e8 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 2b7a3d28c0f..31a346e1921 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 fc1fb715413..a38607aab7a 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 4955d380f9a..4971a41fbcc 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 00000000000..5df3fe6aa36 --- /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 00000000000..90504c9dfa7 --- /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 e8ae3bf92df..df6431f202b 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 8502ea0e35e..83741a6c7a4 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 036c325cdd7..278bfd91884 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 00000000000..e6d4e475936 --- /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 00000000000..3c62c0900bb --- /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 9697b77699e..c5244171f8c 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 3a05575d4f8..6fc0bffe6fe 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 537b1715ce3..928f587afdf 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 e4314d4b0fe..38fd1525c56 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 4ec04d0132f..80b1427cad1 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 e29b8a9e4b9..f6faab19231 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 8b8a3eecece..04b4c01cc21 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 4e686bc7b62..5e02e772f99 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 2f02562f21d..99dc07f12a5 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 4ef306f1dfa..526798cd784 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 03fb5cfae5d..08f40cffb68 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 6608fb69b3c..7d83711f46a 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 239747953e3..8fd0fe8861a 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 30790de2979..6a064e49955 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 98a8b0719c2..32945e73752 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 74638b60340..b651bc58669 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 45457bc85a8..16c940df9c8 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 c1ed827e315..f91e2c720a3 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 97460665f4c..1c26d5d76de 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 139ed96a227..2a57372dbaf 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 e16ba2bac76..194dd0bec30 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 5869e6bc743..99fa18a8276 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 29b599fe5e4..f42c77ad50b 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 0027aa83080..5cb99ddbab0 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 28df91d1e1e..9e92c27f2bb 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 ca338afac91..32f77ef9041 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 4941f148cef..7700b7e20d8 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 96d95a9aee0..247fa4124ce 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 0a4c8e4ba55..c30412c154d 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 9e3add8c785..8ddfdf25756 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 c0c133f2c41..bd7cd1394b1 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 00000000000..6aeec6dd17f --- /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 00000000000..9342712c55e --- /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 00000000000..5a977cd5cce --- /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 00000000000..021d4688443 --- /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 00000000000..5f99c4a9530 --- /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 00000000000..29aa557797a --- /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 00000000000..0948caec680 --- /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 00000000000..58eb8ea2fef --- /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 00000000000..38833438dca --- /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 00000000000..b054ef6a1f6 --- /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 ec9a67f936b..604bcc0fa95 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 a27725c826d..e4f83a13ad1 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 0882c3a7afc..320fd230563 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 57edac645de..fe267d1f9fa 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 0cb788dbd0a..f10f45d53a3 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 5695f286166..a853a29f5e4 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 4a702ff2ab0..856a93c4acb 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 00000000000..33ec558d2d3 --- /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 805cf89b965..229aeb862ee 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 b0e26cbc16a..1e44dbdc00b 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 0e9e9dbe867..816eeb0d06c 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 6aa61ffdaa6..0fcaf0981f9 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 487ee6d1e2c..7a9c2109346 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 ee30158c775..77a3df0d6c3 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 00000000000..0f5fed5de89 --- /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 656425291e5..f0654edd5f6 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 68c2a78f5ba..6e5b95fe952 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 00000000000..3649704b811 --- /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 cb9e94c3b7d..0f14fb28b0e 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 3f16857e309..78c771528b6 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 8240ca2e7f9..55cbd06b5a3 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 0c2e8411fa9..dc64e862cf4 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 64a681c1fcf..4ed749bf645 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 d1d2e177d80..332ed0aa0f9 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 ef6135a8668..9f3452cf93d 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 559f08356a8..e188e779bf7 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 c373b11f8a0..5eff1a6c549 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 a959d130085..eff6dcd3ad5 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 70aa09c2b02..4e62f9cc616 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 af13215ecc5..43200351bcb 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 00000000000..636a37b2a23 --- /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 00000000000..f1dcc4e07c6 --- /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 af2c88485db..0398103546a 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 189849d5c86..16e1da7ccb7 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 87d3b7e84d1..834652fad6d 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 6c79c6bf798..a9f058da795 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 6ca20da4bfe..e3e3a1c9a4e 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({