diff --git a/.changeset/gentle-brooms-sing.md b/.changeset/gentle-brooms-sing.md new file mode 100644 index 000000000..1dbcf4a5b --- /dev/null +++ b/.changeset/gentle-brooms-sing.md @@ -0,0 +1,12 @@ +--- +"@trigger.dev/integration-kit": patch +"@trigger.dev/airtable": patch +"@trigger.dev/shopify": patch +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +"@trigger.dev/cli": patch +--- + +- Simplify `Webhook Triggers` and use the new HTTP Endpoints +- Add a `Key-Value Store` for use in and outside of Jobs +- Add a `@trigger.dev/shopify` package diff --git a/.changeset/soft-ties-turn.md b/.changeset/soft-ties-turn.md deleted file mode 100644 index 0f9393f1e..000000000 --- a/.changeset/soft-ties-turn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/sdk": patch -"@trigger.dev/core": patch ---- - -implement functionality to cancel job runs triggered by a given eventId. diff --git a/.changeset/tricky-games-agree.md b/.changeset/tricky-games-agree.md deleted file mode 100644 index 4d260ed31..000000000 --- a/.changeset/tricky-games-agree.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -set error messages in runTask and executeJob diff --git a/.env.example b/.env.example index 48170e8f3..35b863518 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ APP_ORIGIN=http://localhost:3030 NODE_ENV=development # OPTIONAL VARIABLES +# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected. +# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com" # This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication. # AUTH_GITHUB_CLIENT_ID= # AUTH_GITHUB_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 276a4f633..c83ebf244 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,5 @@ apps/**/public/build /test-results/ /playwright-report/ /playwright/.cache/ + +.cosine \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f5239032..f7666be0e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "deno.enablePaths": ["references/deno-reference"] + "deno.enablePaths": ["references/deno-reference"], + "debug.toolBarLocation": "commandCenter" } diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..32c9b3a9b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct that could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). diff --git a/README.md b/README.md index 22e80d7ad..1919613c6 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,6 @@ -# ✨🎃 Get involved with Hacktoberfest 2023! 🎃✨ - -All of October we're participating in Hacktoberfest and invite you to join us! We have a bunch of issues labeled `🎃 Hacktoberfest` that are ready for you to work on which will count towards Hacktoberfest. We are also running our own game, earn 💎 points to win swag! - -- Check out our [Hacktoberfest landing page](https://trigger.dev/hacktoberfest) for how to participate and win swag. -- Contribute to either our [/trigger.dev](https://github.com/triggerdotdev/trigger.dev/labels/%F0%9F%8E%83%20hacktoberfest), [/api-reference](https://github.com/triggerdotdev/api-reference/issues?q=is%3Aopen+is%3Aissue+label%3A%F0%9F%8E%83hacktoberfest) or [/jobs-showcase](https://github.com/triggerdotdev/jobs-showcase/labels/%F0%9F%8E%83%20hacktoberfest) repositories and complete issues marked `🎃 Hacktoberfest` to be eligible for swag. -- Join our [Discord](https://discord.gg/JtBAxBr2m3) and get involved in with the community. - -_New to Hacktober? Check out the [Hacktoberfest website](https://hacktoberfest.digitalocean.com/) for more information._ - -🎃 **Happy Hacking!** 🎃 - # About Trigger.dev Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays. diff --git a/apps/proxy/.dev.vars.example b/apps/proxy/.dev.vars.example new file mode 100644 index 000000000..76de44a19 --- /dev/null +++ b/apps/proxy/.dev.vars.example @@ -0,0 +1,7 @@ +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 new file mode 100644 index 000000000..64ab2601f --- /dev/null +++ b/apps/proxy/.editorconfig @@ -0,0 +1,13 @@ +# 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 new file mode 100644 index 000000000..3b0fe33c4 --- /dev/null +++ b/apps/proxy/.gitignore @@ -0,0 +1,172 @@ +# 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 new file mode 100644 index 000000000..89c93d85a --- /dev/null +++ b/apps/proxy/.prettierrc @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 000000000..fbaa92bc0 --- /dev/null +++ b/apps/proxy/CHANGELOG.md @@ -0,0 +1,8 @@ +# proxy + +## 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 new file mode 100644 index 000000000..f3010f2af --- /dev/null +++ b/apps/proxy/README.md @@ -0,0 +1,68 @@ +# 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 new file mode 100644 index 000000000..7327db854 --- /dev/null +++ b/apps/proxy/package.json @@ -0,0 +1,21 @@ +{ + "name": "proxy", + "version": "0.0.1", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230419.0", + "typescript": "^5.0.4", + "wrangler": "^3.0.0" + }, + "dependencies": { + "@aws-sdk/client-sqs": "^3.445.0", + "@trigger.dev/core": "workspace:*", + "ulidx": "^2.2.1", + "zod": "3.22.3", + "zod-error": "1.5.0" + } +} diff --git a/apps/proxy/src/apikey.ts b/apps/proxy/src/apikey.ts new file mode 100644 index 000000000..cb6c9c234 --- /dev/null +++ b/apps/proxy/src/apikey.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..d3b2dcce5 --- /dev/null +++ b/apps/proxy/src/events/queueEvent.ts @@ -0,0 +1,87 @@ +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 new file mode 100644 index 000000000..412db29ac --- /dev/null +++ b/apps/proxy/src/events/queueEvents.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 000000000..e68643d88 --- /dev/null +++ b/apps/proxy/src/events/utils.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..b6db2f60f --- /dev/null +++ b/apps/proxy/src/index.ts @@ -0,0 +1,67 @@ +import { queueEvent } from "./events/queueEvent"; +import { queueEvents } from "./events/queueEvents"; + +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; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + if (!env.REWRITE_HOSTNAME) throw new Error("Missing REWRITE_HOSTNAME"); + console.log("url", request.url); + + if (!queueingIsEnabled(env)) { + console.log("Missing AWS credentials. Passing through to the origin."); + return redirectToOrigin(request, env); + } + + const url = new URL(request.url); + switch (url.pathname) { + case "/api/v1/events": { + if (request.method === "POST") { + return queueEvent(request, env); + } + break; + } + case "/api/v1/events/bulk": { + if (request.method === "POST") { + return queueEvents(request, env); + } + break; + } + } + + //the same request but with the hostname (and port) changed + return redirectToOrigin(request, env); + }, +}; + +function redirectToOrigin(request: Request, env: Env) { + const newUrl = new URL(request.url); + newUrl.hostname = env.REWRITE_HOSTNAME; + newUrl.port = env.REWRITE_PORT || newUrl.port; + + const requestInit: RequestInit = { + method: request.method, + headers: request.headers, + body: request.body, + }; + + console.log("rewritten url", newUrl.toString()); + return fetch(newUrl.toString(), requestInit); +} + +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 new file mode 100644 index 000000000..c8c2aca7b --- /dev/null +++ b/apps/proxy/src/json.ts @@ -0,0 +1,13 @@ +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/tsconfig.json b/apps/proxy/tsconfig.json new file mode 100644 index 000000000..b35efe307 --- /dev/null +++ b/apps/proxy/tsconfig.json @@ -0,0 +1,35 @@ +{ + "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 new file mode 100644 index 000000000..930f0de83 --- /dev/null +++ b/apps/proxy/wrangler.toml @@ -0,0 +1,7 @@ +name = "proxy" +main = "src/index.ts" +compatibility_date = "2023-10-30" +compatibility_flags = [ "nodejs_compat" ] + +[env.staging] +[env.prod] \ No newline at end of file diff --git a/apps/webapp/.storybook/preview.tsx b/apps/webapp/.storybook/preview.tsx index bdf5e3ab6..14932b462 100644 --- a/apps/webapp/.storybook/preview.tsx +++ b/apps/webapp/.storybook/preview.tsx @@ -1,6 +1,6 @@ import type { Preview } from "@storybook/react"; import "../app/tailwind.css"; -import { unstable_createRemixStub } from "@remix-run/testing"; +import { createRemixStub } from "@remix-run/testing"; import React from "react"; import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider"; import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider"; @@ -23,13 +23,16 @@ const preview: Preview = { }, ], }, + layout: "fullscreen", }, decorators: [ (Story) => { - const RemixStub = unstable_createRemixStub([ + const RemixStub = createRemixStub([ { path: "/*", - element: , + action: () => ({ redirect: "/" }), + loader: () => ({ redirect: "/" }), + Component: Story, }, ]); diff --git a/apps/webapp/app/assets/icons/EndpointIcon.tsx b/apps/webapp/app/assets/icons/EndpointIcon.tsx new file mode 100644 index 000000000..d491e25a8 --- /dev/null +++ b/apps/webapp/app/assets/icons/EndpointIcon.tsx @@ -0,0 +1,36 @@ +export function EndpointIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/IntegrationIcon.tsx b/apps/webapp/app/assets/icons/IntegrationIcon.tsx new file mode 100644 index 000000000..fd1839228 --- /dev/null +++ b/apps/webapp/app/assets/icons/IntegrationIcon.tsx @@ -0,0 +1,5 @@ +import { LogoIcon } from "~/components/LogoIcon"; + +export function IntegrationIcon() { + return ; +} diff --git a/apps/webapp/app/assets/icons/RunsIcon.tsx b/apps/webapp/app/assets/icons/RunsIcon.tsx new file mode 100644 index 000000000..8688058a6 --- /dev/null +++ b/apps/webapp/app/assets/icons/RunsIcon.tsx @@ -0,0 +1,18 @@ +export function RunsIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx index 72843908a..8ebf2af9d 100644 --- a/apps/webapp/app/components/Feedback.tsx +++ b/apps/webapp/app/components/Feedback.tsx @@ -36,7 +36,8 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) { const [form, { path, feedbackType, message }] = useForm({ id: "accept-invite", - lastSubmission, + // TODO: type this + lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema }); }, diff --git a/apps/webapp/app/components/ImpersonationBanner.tsx b/apps/webapp/app/components/ImpersonationBanner.tsx index d665d344b..d4486fc96 100644 --- a/apps/webapp/app/components/ImpersonationBanner.tsx +++ b/apps/webapp/app/components/ImpersonationBanner.tsx @@ -1,24 +1,18 @@ import { Form } from "@remix-run/react"; import { Paragraph } from "./primitives/Paragraph"; import { Button } from "./primitives/Buttons"; +import { UserMinusIcon } from "@heroicons/react/20/solid"; -export function ImpersonationBanner({ impersonationId }: { impersonationId: string }) { +export function ImpersonationBanner() { return ( -
- - - You are impersonating {impersonationId} - -
+
+ diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx new file mode 100644 index 000000000..86caba799 --- /dev/null +++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx @@ -0,0 +1,105 @@ +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; +import { useFetcher } from "@remix-run/react"; +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { generateTwoRandomWords } from "~/utils/randomWords"; +import { Button } from "../primitives/Buttons"; +import { Header1 } from "../primitives/Headers"; +import { Input } from "../primitives/Input"; +import { Paragraph } from "../primitives/Paragraph"; +import { Spinner } from "../primitives/Spinner"; + +type ModalProps = { + id: string; + title: string; +}; + +type ModalContentProps = ModalProps & { + randomWord: string; + closeModal: () => void; +}; + +export function RegenerateApiKeyModal({ id, title }: ModalProps) { + const randomWord = generateTwoRandomWords(); + const [open, setOpen] = useState(false); + return ( + + + + + + {`Regenerate ${title} Environment Key`} + setOpen(false)} + /> + + + ); +} + +const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => { + const [confirmationText, setConfirmationText] = useState(""); + const fetcher = useFetcher(); + const isSubmitting = fetcher.state === "submitting"; + + // form submission completed + if (fetcher.state === "loading") { + closeModal(); + } + + return ( +
+
+ + + Regenerating the keys for this environment will temporarily break any live Jobs in the + {title} Environment until the new API keys are set + in the relevant environment variables. + +
+ +
+ Enter this text below to confirm: + + {randomWord} + +
+
+ setConfirmationText(e.target.value)} + className="rounded-r-none" + variant="large" + /> + +
+
+
+ ); +}; diff --git a/apps/webapp/app/components/frameworks/FrameworkComingSoon.tsx b/apps/webapp/app/components/frameworks/FrameworkComingSoon.tsx index c17848a11..da30882e4 100644 --- a/apps/webapp/app/components/frameworks/FrameworkComingSoon.tsx +++ b/apps/webapp/app/components/frameworks/FrameworkComingSoon.tsx @@ -3,11 +3,10 @@ import { GitHubDarkIcon } from "@trigger.dev/companyicons"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { projectSetupPath } from "~/utils/pathBuilder"; -import { PageGradient } from "../PageGradient"; import { LinkButton } from "../primitives/Buttons"; import { Header1 } from "../primitives/Headers"; -import { Paragraph } from "../primitives/Paragraph"; import { NamedIcon } from "../primitives/NamedIcon"; +import { Paragraph } from "../primitives/Paragraph"; export type FrameworkComingSoonProps = { frameworkName: string; githubIssueUrl: string; @@ -25,43 +24,41 @@ export function FrameworkComingSoon({ const project = useProject(); return ( - -
-
{children}
-
- {frameworkName} is coming soon! - - Choose a different framework - -
- - We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with - the GitHub issue or contribute and help us bring it to Trigger.dev faster. - - +
{children}
+
- + + We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with + the GitHub issue or contribute and help us bring it to Trigger.dev faster. + + + triggerdotdev/trigger.dev +

+ #{githubIssueNumber}Framework: + support for {frameworkName} +

+
+ + View on GitHub + +
+
+
); } diff --git a/apps/webapp/app/components/frameworks/FrameworkSelector.tsx b/apps/webapp/app/components/frameworks/FrameworkSelector.tsx index 3ce50b1e5..993a0bc64 100644 --- a/apps/webapp/app/components/frameworks/FrameworkSelector.tsx +++ b/apps/webapp/app/components/frameworks/FrameworkSelector.tsx @@ -24,7 +24,6 @@ import { projectSetupSvelteKitPath, } from "~/utils/pathBuilder"; import { Feedback } from "../Feedback"; -import { PageGradient } from "../PageGradient"; import { Button } from "../primitives/Buttons"; import { Header1 } from "../primitives/Headers"; @@ -33,51 +32,49 @@ export function FrameworkSelector() { const project = useProject(); return ( - -
-
- Choose a framework to get started… - - Request a framework - - } - defaultValue="feature" - /> -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+
+ Choose a framework to get started… + + Request a framework + + } + defaultValue="feature" + />
- + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
); } @@ -93,8 +90,7 @@ function FrameworkLink({ children, to, supported = false }: FrameworkLinkProps) to={to} className={cn( "flex h-28 w-full items-center justify-center rounded-md border border-slate-750 px-8 py-4 transition hover:bg-slate-850", - !supported && - "border-dashed opacity-50 grayscale transition hover:opacity-100 hover:grayscale-0" + !supported && "border opacity-30 grayscale transition hover:opacity-100 hover:grayscale-0" )} > {children} diff --git a/apps/webapp/app/components/helpContent/HelpContentText.tsx b/apps/webapp/app/components/helpContent/HelpContentText.tsx index ce057dae4..78d4807f5 100644 --- a/apps/webapp/app/components/helpContent/HelpContentText.tsx +++ b/apps/webapp/app/components/helpContent/HelpContentText.tsx @@ -4,8 +4,7 @@ import { StepNumber } from "~/components/primitives/StepNumber"; import { useJob } from "~/hooks/useJob"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { IntegrationIcon } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations/route"; -import { jobTestPath } from "~/utils/pathBuilder"; +import { docsPath, jobTestPath } from "~/utils/pathBuilder"; import { CodeBlock } from "../code/CodeBlock"; import { InlineCode } from "../code/InlineCode"; import { EnvironmentLabel } from "../environments/EnvironmentLabel"; @@ -21,6 +20,7 @@ import selectEnvironment from "./select-environment.png"; import selectExample from "./select-example.png"; import { StepContentContainer } from "../StepContentContainer"; import { TriggerDevCommand } from "../SetupCommands"; +import { IntegrationIcon } from "~/assets/icons/IntegrationIcon"; export function HowToRunYourJob() { const organization = useOrganization(); @@ -294,3 +294,140 @@ export function HowToUseApiKeysAndEndpoints() { ); } + +export function WhatAreHttpEndpoints() { + return ( + <> + + HTTP endpoints allow you to trigger your Jobs from any webhooks. They require a bit more + work than using Integrations{" "} + but allow you to connect to any API. + + Getting started + + You need to define the HTTP endpoint in your code. To do this you use{" "} + client.defineHttpEndpoint(). This will create an HTTP endpoint. + + + Then you can create a Trigger from this by calling .onRequest() on + the created HTTP endpoint. + + + Read the HTTP endpoints guide to learn more. + + + An example: cal.com + + { + //this helper function makes verifying most webhooks easy + return await verifyRequestSignature({ + request, + headerName: "X-Cal-Signature-256", + secret: process.env.CALDOTCOM_SECRET!, + algorithm: "sha256", + }); + }, +}); + +client.defineJob({ + id: "http-caldotcom", + name: "HTTP Cal.com", + version: "1.0.0", + enabled: true, + //create a Trigger from the HTTP endpoint above. The filter is optional. + trigger: caldotcom.onRequest({ filter: { body: { triggerEvent: ["BOOKING_CANCELLED"] } } }), + run: async (request, io, ctx) => { + //note that when using HTTP endpoints, the first parameter is the request + //you need to get the body, usually it will be json so you do: + const body = await request.json(); + await io.logger.info("Body", body); + }, +});`} + /> + + ); +} + +export function HowToConnectHttpEndpoint() { + return ( + <> + Setting up your webhook + + To start receiving data you need to enter the Endpoint URL and secret into the API service + you want to receive webhooks from. + + + Go to the relevant API dashboard} /> + + + For example, if you want to receive webhooks from Cal.com then you should login to your + Cal.com account and go to their Settings/Developer/Webhooks page. + + + + Copy the Webhook URL and Secret} /> + + + A unique Webhook URL is created for each environment (Dev, Staging, and Prod). Jobs will + only be triggered from the relevant environment. + + + Copy the relevant Endpoint URL and secret from the table opposite and paste it into the + correct place in the API dashboard you located in the previous step. + + + + Add the Secret to your Environment variables} /> + + + You should also add the Secret to the Environment variables in your code and where you're + deploying. Usually in Node this means adding it to the .env file. + + + Use the secret in the verify() function of HTTP Endpoint. This + ensures that someone can't just send a request to your Endpoint and trigger a Job. + Different APIs do this verification in different ways – a common way is to have a header + that has a hash of the payload and secret. Refer to the API's documentation for more + information. + + + + Triggering runs + + + + In your code, you should use the .onRequest() function in a Job + Trigger. You can filter so only data that matches your criteria triggers the Job. + + + + + + If you're using the Staging or Prod environment, you need to make sure your code is + deployed. Deploy like you normally would –{" "} + + read our deployment guide + + . + + + + + + Now you need to actually perform an action on that third-party service that triggers the + webhook you've subscribed to. For example, add a new meeting using Cal.com. + + + + + Read the HTTP endpoints guide to learn more. + + + ); +} diff --git a/apps/webapp/app/components/integrations/ConnectToIntegrationSheet.tsx b/apps/webapp/app/components/integrations/ConnectToIntegrationSheet.tsx index 3811b2390..d5a121342 100644 --- a/apps/webapp/app/components/integrations/ConnectToIntegrationSheet.tsx +++ b/apps/webapp/app/components/integrations/ConnectToIntegrationSheet.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { ApiAuthenticationMethodApiKey, Integration } from "~/services/externalApis/types"; -import { docsIntegrationPath } from "~/utils/pathBuilder"; +import { Integration } from "~/services/externalApis/types"; +import { apiReferencePath, docsIntegrationPath } from "~/utils/pathBuilder"; import { LinkButton } from "../primitives/Buttons"; import { Header1, Header2 } from "../primitives/Headers"; import { NamedIconInBox } from "../primitives/NamedIcon"; @@ -48,6 +48,15 @@ export function ConnectToIntegrationSheet({ {integration.description} )}
+ + View examples + )} - {integrationMethod && ( @@ -132,7 +134,5 @@ function SelectedIntegrationMethod({ callbackUrl={callbackUrl} /> ); - case "custom": - return ; } } diff --git a/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx b/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx index a997ab2c3..ae7a7f728 100644 --- a/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx +++ b/apps/webapp/app/components/integrations/ConnectToOAuthForm.tsx @@ -46,7 +46,8 @@ export function ConnectToOAuthForm({ const [form, { title, slug, scopes, hasCustomClient, customClientId, customClientSecret }] = useForm({ - lastSubmission: fetcher.data, + // TODO: type this + lastSubmission: fetcher.data as any, shouldRevalidate: "onSubmit", onValidate({ formData }) { return parse(formData, { diff --git a/apps/webapp/app/components/integrations/CustomHelp.tsx b/apps/webapp/app/components/integrations/CustomHelp.tsx index e5a7bb60b..dfdd2107f 100644 --- a/apps/webapp/app/components/integrations/CustomHelp.tsx +++ b/apps/webapp/app/components/integrations/CustomHelp.tsx @@ -1,79 +1,124 @@ -import { CodeBlock } from "../code/CodeBlock"; +import { useState } from "react"; +import { CodeExample } from "~/routes/resources.codeexample"; +import { Api } from "~/services/externalApis/apis.server"; +import { cn } from "~/utils/cn"; +import { Feedback } from "../Feedback"; import { Header1, Header2 } from "../primitives/Headers"; import { Paragraph } from "../primitives/Paragraph"; +import { TextLink } from "../primitives/TextLink"; + +const fallbackExamples = [ + { + title: "Post to Slack when meetings are booked or cancelled.", + slug: "cal-slack-meeting-alert", + version: "1.0.0", + codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/cal-http-endpoint.ts", + }, + { + title: "Translate some text with DeepL.", + slug: "translate-text-with-deepl", + version: "1.0.0", + codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/deepl.ts", + }, + { + title: "Create a Discord bot and send a message to a channel.", + slug: "discord-bot-send-message", + version: "1.0.0", + codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/discord.ts", + }, + { + title: "Retrieve a Notion page by ID.", + slug: "retrieve-notion-page", + version: "1.0.0", + codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/notion.ts", + }, +]; + +export function CustomHelp({ api }: { api: Api }) { + const [selectedExample, setSelectedExample] = useState(0); + + const changeCodeExample = (index: number) => { + setSelectedExample(index); + }; -export function CustomHelp({ name }: { name: string }) { return (
- You can use any API with requests or an SDK - How to use an SDK + Using an API with an SDK or requests - You can call SDK methods from inside the run function, but you should wrap them in a Task to - make sure they're resumable. + You can use Trigger.dev with any existing Node SDK or even just using fetch. You can + subscribe to any API with{" "} + + HTTP endpoints + {" "} + and perform actions by wrapping tasks using{" "} + + io.runTask + + . This makes your background job resumable and appear in our dashboard. - Here's an example with the official GitHub SDK - { - //wrap an SDK call in io.runTask so it's resumable and displays in logs - const repo = await io.runTask( - "Get repo", - async () => { - //this is the regular GitHub SDK - const response = await octokit.rest.repos.get({ - owner: "triggerdotdev", - repo: "trigger.dev", - }); - return response.data; - }, - //you can add metadata to the task to improve the display in the logs - { name: "Get repo", icon: "github" } - ); - }, -}); - `} - highlightedRanges={[[9, 22]]} - className="mb-4" - /> - How to use fetch - - You can use the fetch API to make requests to any API. Or a different request library like - axios if you'd prefer. Again wrapping the request in a Task will make sure it's resumable. - - { - //wrap anything in io.runTask so it's resumable and displays in logs - const repo = await io.runTask( - "Get org", - async () => { - //you can use fetch, axios, or any other library to make requests - const response = await fetch('https://api.github.com/orgs/nodejs'); - return response.json(); - }, - //you can add metadata to the task to improve the display in the logs - { name: "Get org", icon: "github" } - ); - }, -}); - `} - highlightedRanges={[[9, 19]]} - className="mb-4" - /> + + {api.examples && api.examples.length > 0 ? ( + <> + Example {api.name} code + + This is how you can use {api.name} with Trigger.dev. This code can be copied and + modified to suit your use-case. + + {api.examples.length > 1 && ( +
+ {api.examples?.map((example, index) => ( + + ))} +
+ )} + + + ) : ( + <> + Example code using fetch / an existing SDK + + You can use one of our examples below as a starting point / reference for your projects. + Please{" "} + + reach out to us + + } + defaultValue="help" + />{" "} + if you're having any issues. + + +
+ {fallbackExamples.map((example, index) => ( + + ))} +
+ + + )}
); } diff --git a/apps/webapp/app/components/integrations/NoIntegrationSheet.tsx b/apps/webapp/app/components/integrations/NoIntegrationSheet.tsx index fc06a2b6d..6998ffbab 100644 --- a/apps/webapp/app/components/integrations/NoIntegrationSheet.tsx +++ b/apps/webapp/app/components/integrations/NoIntegrationSheet.tsx @@ -1,14 +1,10 @@ +import { useFetcher } from "@remix-run/react"; import React from "react"; -import { Api } from "~/services/externalApis/apis"; -import { Button } from "../primitives/Buttons"; -import { Callout } from "../primitives/Callout"; +import { Api } from "~/services/externalApis/apis.server"; import { Header1 } from "../primitives/Headers"; import { NamedIconInBox } from "../primitives/NamedIcon"; import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "../primitives/Sheet"; import { CustomHelp } from "./CustomHelp"; -import { CheckIcon } from "@heroicons/react/24/solid"; -import { useFetcher } from "@remix-run/react"; -import { Paragraph } from "../primitives/Paragraph"; export function NoIntegrationSheet({ api, @@ -31,27 +27,9 @@ export function NoIntegrationSheet({ {api.name}
- {requested ? ( -
- - - We'll let you know when the Integration is available. - -
- ) : ( - - - - )} - + diff --git a/apps/webapp/app/components/integrations/UpdateOAuthForm.tsx b/apps/webapp/app/components/integrations/UpdateOAuthForm.tsx index 85db2c6de..aeadde9ea 100644 --- a/apps/webapp/app/components/integrations/UpdateOAuthForm.tsx +++ b/apps/webapp/app/components/integrations/UpdateOAuthForm.tsx @@ -45,7 +45,8 @@ export function UpdateOAuthForm({ const { isManagedCloud } = useFeatures(); const [form, { title, scopes, hasCustomClient, customClientId, customClientSecret }] = useForm({ - lastSubmission: fetcher.data, + // TODO: type this + lastSubmission: fetcher.data as any, onValidate({ formData }) { return parse(formData, { schema, diff --git a/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx b/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx index f9fdf2775..eccd9ff31 100644 --- a/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx +++ b/apps/webapp/app/components/jobs/DeleteJobModalContent.tsx @@ -40,7 +40,7 @@ export function DeleteJobDialogContent({ return (
-
+
{title} ID: {slug}
diff --git a/apps/webapp/app/components/jobs/JobsTable.tsx b/apps/webapp/app/components/jobs/JobsTable.tsx index 8f2e4fb84..f785fba03 100644 --- a/apps/webapp/app/components/jobs/JobsTable.tsx +++ b/apps/webapp/app/components/jobs/JobsTable.tsx @@ -1,7 +1,7 @@ -import { ProjectJob } from "~/hooks/useJobs"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { JobRunStatus } from "~/models/job.server"; +import { ProjectJob } from "~/presenters/JobListPresenter.server"; import { jobPath, jobTestPath } from "~/utils/pathBuilder"; import { Button } from "../primitives/Buttons"; import { DateTime } from "../primitives/DateTime"; @@ -27,10 +27,9 @@ import { JobStatusBadge } from "./JobStatusBadge"; export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) { const organization = useOrganization(); - const project = useProject(); return ( - +
Job @@ -45,7 +44,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul {jobs.length > 0 ? ( jobs.map((job) => { - const path = jobPath(organization, project, job); + const path = jobPath(organization, { slug: job.projectSlug }, job); return ( @@ -153,26 +152,25 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul - - - - + Delete Job + diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx index d1099ead1..413c481da 100644 --- a/apps/webapp/app/components/layout/AppLayout.tsx +++ b/apps/webapp/app/components/layout/AppLayout.tsx @@ -11,7 +11,7 @@ export function AppContainer({ }) { return ( -
{children}
+
{children}
); } @@ -47,7 +47,11 @@ export function PageContainer({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
+ {children} +
+ ); } export function PageBody({ @@ -58,7 +62,15 @@ export function PageBody({ scrollable?: boolean; }) { return ( -
{children}
+
+ {children} +
); } @@ -68,8 +80,8 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) { export function MainCenteredContainer({ children }: { children: React.ReactNode }) { return ( -
-
{children}
+
+
{children}
); } diff --git a/apps/webapp/app/components/navigation/Breadcrumb.tsx b/apps/webapp/app/components/navigation/Breadcrumb.tsx index 64980c9e9..6cad225f0 100644 --- a/apps/webapp/app/components/navigation/Breadcrumb.tsx +++ b/apps/webapp/app/components/navigation/Breadcrumb.tsx @@ -1,26 +1,35 @@ -import { RouteMatch, useMatches } from "@remix-run/react"; +import { UIMatch, useMatches } from "@remix-run/react"; import { Fragment, ReactNode } from "react"; import { BreadcrumbIcon } from "../primitives/BreadcrumbIcon"; +import { Handle } from "~/utils/handle"; +import { LinkButton } from "../primitives/Buttons"; -export type BreadcrumbItem = (match: RouteMatch, allMatches: RouteMatch[]) => ReactNode; +export type BreadcrumbItem = (match: UIMatch, allMatches: UIMatch[]) => ReactNode; export function Breadcrumb() { - const matches = useMatches(); + const matches = useMatches() as UIMatch[]; return ( -
- {matches.map((match) => { - if (!match.handle || !match.handle.breadcrumb) return null; +
+ {matches + .filter((b) => b.handle && b.handle.breadcrumb) + .map((match, index) => { + const breadcrumb = match.handle.breadcrumb as BreadcrumbItem; - const breadcrumb = match.handle.breadcrumb as BreadcrumbItem; - - return ( - - - {breadcrumb(match, matches)} - - ); - })} + return ( + + {index !== 0 && } {breadcrumb(match, matches)} + + ); + })}
); } + +export function BreadcrumbLink({ title, to }: { title: string; to: string }) { + return ( + + {title} + + ); +} diff --git a/apps/webapp/app/components/navigation/JobsMenu.tsx b/apps/webapp/app/components/navigation/JobsMenu.tsx deleted file mode 100644 index 351f62bb2..000000000 --- a/apps/webapp/app/components/navigation/JobsMenu.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Link, RouteMatch } from "@remix-run/react"; -import { useState } from "react"; -import { useJob } from "~/hooks/useJob"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { useJobs } from "~/hooks/useJobs"; -import { cn } from "~/utils/cn"; -import { jobPath } from "~/utils/pathBuilder"; -import { LabelValueStack } from "../primitives/LabelValueStack"; -import { NamedIcon } from "../primitives/NamedIcon"; -import { - Popover, - PopoverArrowTrigger, - PopoverContent, - PopoverSectionHeader, -} from "../primitives/Popover"; - -export function JobsMenu({ matches }: { matches: RouteMatch[] }) { - const [isOpen, setIsOpen] = useState(false); - const organization = useOrganization(matches); - const project = useProject(matches); - const projectJobs = useJobs(matches); - const currentJob = useJob(matches); - - return ( - <> - setIsOpen(open)}> - - {currentJob?.title ?? "Select a job"} - - - -
- {projectJobs.map((job) => { - const isSelected = job.id === currentJob?.id; - return ( - - - - {isSelected && } - - ); - })} -
-
-
- - ); -} diff --git a/apps/webapp/app/components/navigation/NavBar.tsx b/apps/webapp/app/components/navigation/NavBar.tsx deleted file mode 100644 index 9f09cd72b..000000000 --- a/apps/webapp/app/components/navigation/NavBar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Popover, Transition } from "@headlessui/react"; -import { BookOpenIcon, ChatBubbleLeftRightIcon } from "@heroicons/react/20/solid"; -import { Link } from "@remix-run/react"; -import { Fragment } from "react"; -import { cn } from "~/utils/cn"; -import { Feedback } from "../Feedback"; -import { LogoIcon } from "../LogoIcon"; -import { Button, LinkButton } from "../primitives/Buttons"; -import { Breadcrumb } from "./Breadcrumb"; -import { docsRoot } from "~/utils/pathBuilder"; - -export function NavBar() { - return ( -
-
- - - - -
-
- - Documentation - - - Help & feedback - - } - /> -
-
- ); -} - -export function BreadcrumbLink({ title, to }: { title: string; to: string }) { - return ( - - {title} - - ); -} diff --git a/apps/webapp/app/components/navigation/PageNavigationIndicator.tsx b/apps/webapp/app/components/navigation/PageNavigationIndicator.tsx new file mode 100644 index 000000000..3fb255ac5 --- /dev/null +++ b/apps/webapp/app/components/navigation/PageNavigationIndicator.tsx @@ -0,0 +1,9 @@ +import { useNavigation } from "@remix-run/react"; +import { Spinner } from "../primitives/Spinner"; + +export function PageNavigationIndicator() { + const navigation = useNavigation(); + if (navigation.state === "loading") { + return ; + } +} diff --git a/apps/webapp/app/components/navigation/ProjectSideMenu.tsx b/apps/webapp/app/components/navigation/ProjectSideMenu.tsx deleted file mode 100644 index 91a6cdf6d..000000000 --- a/apps/webapp/app/components/navigation/ProjectSideMenu.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useMatches } from "@remix-run/react"; -import { motion } from "framer-motion"; -import { useOptionalJob } from "~/hooks/useJob"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { cn } from "~/utils/cn"; -import { Handle } from "~/utils/handle"; -import { - accountPath, - organizationBillingPath, - organizationTeamPath, - projectEnvironmentsPath, - projectIntegrationsPath, - projectPath, - projectSetupPath, - projectTriggersPath, -} from "~/utils/pathBuilder"; -import { UserProfilePhoto } from "../UserProfilePhoto"; -import { NavLinkButton } from "../primitives/Buttons"; -import { NamedIcon, type IconNames } from "../primitives/NamedIcon"; -import { SimpleTooltip } from "../primitives/Tooltip"; - -export function SideMenuContainer({ children }: { children: React.ReactNode }) { - return
{children}
; -} - -const expandedWith = "14rem"; -const collapsedWith = "2.81rem"; - -const menuVariants = { - expanded: { - minWidth: expandedWith, - width: expandedWith, - }, - collapsed: { - minWidth: collapsedWith, - width: collapsedWith, - }, -}; - -export function ProjectSideMenu() { - const organization = useOrganization(); - const project = useProject(); - const matches = useMatches(); - - //the deepest route `handle` determines if the menu is expanded - const deepestMatch = matches.at(-1); - const handle = deepestMatch?.handle as Handle; - const isCollapsed = handle?.expandSidebar ? !handle.expandSidebar : true; - - const job = useOptionalJob(); - const jobsActive = - job !== undefined || - deepestMatch?.id === "routes/_app.orgs.$organizationSlug.projects.$projectParam._index"; - - return ( - -
- - - - -
-
- - - - - -
-
- ); -} - -const itemVariants = { - expanded: { - opacity: 1, - }, - collapsed: { - opacity: 0, - }, -}; - -function SideMenuItem({ - icon, - name, - to, - isCollapsed, - forceActive, - hasWarning = false, - target, -}: { - icon: IconNames | React.ComponentType; - name: string; - to: string; - isCollapsed: boolean; - hasWarning?: boolean; - forceActive?: boolean; - target?: string; -}) { - return ( - { - if (forceActive !== undefined) { - isActive = forceActive; - } - return cn( - "relative", - isActive || isPending - ? "bg-slate-800 text-bright group-hover:bg-slate-800" - : "text-dimmed group-hover:bg-slate-850 group-hover:text-bright" - ); - }} - > - - {name} - - {hasWarning && } - - } - content={name} - side="right" - hidden={!isCollapsed} - /> - ); -} diff --git a/apps/webapp/app/components/navigation/ProjectsMenu.tsx b/apps/webapp/app/components/navigation/ProjectsMenu.tsx deleted file mode 100644 index bd9d98850..000000000 --- a/apps/webapp/app/components/navigation/ProjectsMenu.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { RouteMatch } from "@remix-run/react"; -import { Fragment, useState } from "react"; -import simplur from "simplur"; -import { Badge } from "~/components/primitives/Badge"; -import { useIsNewOrganizationPage, useOrganizations } from "~/hooks/useOrganizations"; -import { useOptionalProject } from "~/hooks/useProject"; -import { newOrganizationPath, newProjectPath, projectPath } from "~/utils/pathBuilder"; -import { - Popover, - PopoverArrowTrigger, - PopoverContent, - PopoverMenuItem, - PopoverSectionHeader, -} from "../primitives/Popover"; - -export function ProjectsMenu({ matches }: { matches: RouteMatch[] }) { - const [isOpen, setIsOpen] = useState(false); - const organizations = useOrganizations(matches); - const isNewOrgPage = useIsNewOrganizationPage(matches); - const currentProject = useOptionalProject(matches); - - if (isNewOrgPage) { - return null; - } - - return ( - <> - setIsOpen(open)}> - - {currentProject?.name ?? "Select a project"} - - - {organizations.map((organization) => ( - - -
- {organization.projects.map((project) => { - const isSelected = project.id === currentProject?.id; - return ( - - {project.name} - {simplur`${project._count.jobs} job[|s]`} -
- } - isSelected={isSelected} - icon="folder" - /> - ); - })} - -
- - ))} -
- -
- - - - ); -} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx new file mode 100644 index 000000000..75b7a2ada --- /dev/null +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -0,0 +1,431 @@ +import { + AcademicCapIcon, + ArrowRightOnRectangleIcon, + ChartBarIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid"; +import { useNavigation } from "@remix-run/react"; +import { IconExclamationCircle } from "@tabler/icons-react"; +import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react"; +import { MatchedOrganization } from "~/hooks/useOrganizations"; +import { usePathName } from "~/hooks/usePathName"; +import { MatchedProject } from "~/hooks/useProject"; +import { User } from "~/models/user.server"; +import { cn } from "~/utils/cn"; +import { + accountPath, + inviteTeamMemberPath, + logoutPath, + newOrganizationPath, + newProjectPath, + organizationBillingPath, + organizationIntegrationsPath, + organizationPath, + organizationTeamPath, + projectEnvironmentsPath, + projectHttpEndpointsPath, + projectPath, + projectSetupPath, + projectTriggersPath, +} from "~/utils/pathBuilder"; +import { Feedback } from "../Feedback"; +import { ImpersonationBanner } from "../ImpersonationBanner"; +import { LogoIcon } from "../LogoIcon"; +import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto"; +import { Button, LinkButton } from "../primitives/Buttons"; +import { Icon } from "../primitives/Icon"; +import { type IconNames } from "../primitives/NamedIcon"; +import { Paragraph } from "../primitives/Paragraph"; +import { + Popover, + PopoverArrowTrigger, + PopoverContent, + PopoverCustomTrigger, + PopoverMenuItem, + PopoverSectionHeader, +} from "../primitives/Popover"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip"; +import { useFeatures } from "~/hooks/useFeatures"; + +type SideMenuUser = Pick & { isImpersonating: boolean }; +type SideMenuProject = Pick< + MatchedProject, + "id" | "name" | "slug" | "hasInactiveExternalTriggers" | "jobCount" | "httpEndpointCount" +>; + +type SideMenuProps = { + user: SideMenuUser; + project: SideMenuProject; + organization: MatchedOrganization; + organizations: MatchedOrganization[]; +}; + +export function SideMenu({ user, project, organization, organizations }: SideMenuProps) { + const borderRef = useRef(null); + const [showHeaderDivider, setShowHeaderDivider] = useState(false); + const { isManagedCloud } = useFeatures(); + + useEffect(() => { + const handleScroll = () => { + if (borderRef.current) { + const shouldShowHeaderDivider = borderRef.current.scrollTop > 1; + if (showHeaderDivider !== shouldShowHeaderDivider) { + setShowHeaderDivider(shouldShowHeaderDivider); + } + } + }; + + borderRef.current?.addEventListener("scroll", handleScroll); + return () => borderRef.current?.removeEventListener("scroll", handleScroll); + }, [showHeaderDivider]); + + return ( +
+
+
+ + +
+
+
+ + + + + + + +
+
+ + + + + + + + +
+
+
+ + + + + Help & Feedback + + } + /> +
+
+
+ ); +} + +function ProjectSelector({ + project, + organization, + organizations, +}: { + project: SideMenuProject; + organization: MatchedOrganization; + organizations: MatchedOrganization[]; +}) { + const [isOrgMenuOpen, setOrgMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setOrgMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( + setOrgMenuOpen(open)} open={isOrgMenuOpen}> + + + {organization.title ?? "Select an organization"} + + + {organizations.map((organization) => ( + + +
+ {organization.projects.map((p) => { + const isSelected = p.id === project.id; + return ( + + {p.name} + +
+ } + isSelected={isSelected} + icon="folder" + /> + ); + })} +
+ + ))} +
+ +
+ + + ); +} + +function UserMenu({ user }: { user: SideMenuUser }) { + const [isProfileMenuOpen, setProfileMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setProfileMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( + setProfileMenuOpen(open)}> + + + + + + +
+ {user.isImpersonating && } + {user.admin && ( + + )} + + +
+
+
+
+ ); +} + +function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) { + const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setHeaderMenuOpen(false); + }, [navigation.location?.pathname]); + + return ( +
+ + {title} + + setHeaderMenuOpen(open)} open={isHeaderMenuOpen}> + + + + +
{children}
+
+
+
+ ); +} + +function SideMenuItem({ + icon, + iconColor, + name, + to, + hasWarning, + count, + target, + subItem = false, +}: { + icon?: IconNames | React.ComponentType; + iconColor?: string; + name: string; + to: string; + hasWarning?: string | boolean; + count?: number; + target?: AnchorHTMLAttributes["target"]; + subItem?: boolean; +}) { + const pathName = usePathName(); + const isActive = pathName === to; + + return ( + +
+ {name} +
+ {count !== undefined && count > 0 && } + {typeof hasWarning === "string" ? ( + + + + + + + {hasWarning} + + + + ) : ( + hasWarning && + )} +
+
+
+ ); +} + +function MenuCount({ count }: { count: number | string }) { + return
{count}
; +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index e469c2561..014b9096a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -7,99 +7,129 @@ import { ShortcutKey } from "./ShortcutKey"; const variant = { "primary/small": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80", + textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1", button: "h-6 px-[5px] text-xs bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:pointer-events-none", icon: "h-3.5", + iconSpacing: "gap-x-0.5", shortcutVariant: "small" as const, shortcut: "ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60 justify-self-center", }, "secondary/small": { - textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80", + textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1", button: "h-6 px-[5px] text-xs bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50 group-disabled:pointer-events-none", icon: "h-3.5", + iconSpacing: "gap-x-0.5", shortcutVariant: "small" as const, shortcut: "ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60", }, "tertiary/small": { - textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80", + textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1", button: "h-6 px-[5px] text-xs bg-transparent group-hover:bg-slate-850 disabled:opacity-50 group-disabled:pointer-events-none", icon: "h-3.5", + iconSpacing: "gap-x-0.5", shortcutVariant: "small" as const, shortcut: "ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60", }, "danger/small": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80", + textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1", button: "h-6 px-[5px] text-xs bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50 group-disabled:pointer-events-none", icon: "h-3.5", + iconSpacing: "gap-x-0.5", shortcutVariant: "small" as const, shortcut: "ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60", }, "primary/medium": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80", + textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1", button: "h-8 px-2 text-sm bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50", icon: "h-4", + iconSpacing: "gap-x-0.5", shortcutVariant: "medium" as const, shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60", }, "secondary/medium": { - textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80", + textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1", button: "h-8 px-2 text-sm bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50", icon: "h-4", + iconSpacing: "gap-x-0.5", shortcutVariant: "medium" as const, shortcut: "ml-1.5 -mr-0.5 border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright", }, "tertiary/medium": { - textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80", + textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1", button: "h-8 px-2 text-sm bg-transparent group-hover:bg-slate-850 disabled:opacity-50", icon: "h-4", + iconSpacing: "gap-x-0.5", shortcutVariant: "medium" as const, shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright", }, "danger/medium": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80", + textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1", button: "h-8 px-2 text-sm bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50", icon: "h-4", + iconSpacing: "gap-x-0.5", shortcutVariant: "medium" as const, shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60", }, "primary/large": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80", + textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1", button: - "h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50", + "h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50", icon: "h-5", + iconSpacing: "gap-x-0.5", shortcutVariant: undefined, shortcut: undefined, }, "secondary/large": { - textColor: "text-dimmed", + textColor: "text-dimmed px-1", button: "h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50", icon: "h-5", + iconSpacing: "gap-x-0.5", shortcutVariant: undefined, shortcut: undefined, }, "danger/large": { - textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50", + textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50 px-1", button: "h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600", icon: "h-5", + iconSpacing: "gap-x-0.5", shortcutVariant: "medium" as const, shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60", }, "menu-item": { - textColor: "text-bright", + textColor: "text-bright px-1", button: "h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-slate-800 transition", icon: "h-5", + iconSpacing: "gap-x-0.5", + shortcutVariant: undefined, + shortcut: undefined, + }, + "small-menu-item": { + textColor: "text-bright", + button: + "h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition", + icon: "h-4", + iconSpacing: "gap-x-1.5", + shortcutVariant: undefined, + shortcut: undefined, + }, + "small-menu-sub-item": { + textColor: "text-dimmed", + button: + "h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition", + icon: undefined, + iconSpacing: undefined, shortcutVariant: undefined, shortcut: undefined, }, @@ -110,7 +140,7 @@ const allVariants = { variant: variant, }; -type ButtonContentPropsType = { +export type ButtonContentPropsType = { children?: React.ReactNode; LeadingIcon?: React.ComponentType | IconNamesOrString; TrailingIcon?: React.ComponentType | IconNamesOrString; @@ -140,6 +170,7 @@ export function ButtonContent(props: ButtonContentPropsType) { // Based on the size prop, we'll use the corresponding variant classnames const btnClassName = cn(allVariants.$all, variation.button); const iconClassName = variation.icon; + const iconSpacingClassName = variation.iconSpacing; const shortcutClassName = variation.shortcut; const textColorClassName = variation.textColor; @@ -148,7 +179,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
{LeadingIcon && @@ -170,7 +202,7 @@ export function ButtonContent(props: ButtonContentPropsType) { {text && (typeof text === "string" ? ( - + {text} ) : ( diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index fd6cabc6c..e84c64863 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -43,12 +43,12 @@ const DialogContent = React.forwardRef< -
+
{children}
@@ -73,7 +73,7 @@ const DialogContent = React.forwardRef< DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+
); DialogHeader.displayName = "DialogHeader"; diff --git a/apps/webapp/app/components/primitives/FormTitle.tsx b/apps/webapp/app/components/primitives/FormTitle.tsx index 02a33ae16..98b8c635b 100644 --- a/apps/webapp/app/components/primitives/FormTitle.tsx +++ b/apps/webapp/app/components/primitives/FormTitle.tsx @@ -21,7 +21,7 @@ export function FormTitle({
diff --git a/apps/webapp/app/components/primitives/Help.tsx b/apps/webapp/app/components/primitives/Help.tsx index da126acba..816b2e3f7 100644 --- a/apps/webapp/app/components/primitives/Help.tsx +++ b/apps/webapp/app/components/primitives/Help.tsx @@ -88,12 +88,7 @@ export function HelpContent({ )}
-
+
{children}
diff --git a/apps/webapp/app/components/primitives/LabelValueStack.tsx b/apps/webapp/app/components/primitives/LabelValueStack.tsx index c271ea296..a8ff3e1c2 100644 --- a/apps/webapp/app/components/primitives/LabelValueStack.tsx +++ b/apps/webapp/app/components/primitives/LabelValueStack.tsx @@ -2,6 +2,7 @@ import { cn } from "~/utils/cn"; import { Paragraph } from "./Paragraph"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { SimpleTooltip } from "./Tooltip"; +import { Link } from "@remix-run/react"; const variations = { primary: { @@ -45,18 +46,7 @@ export function LabelValueStack({ {label} <> {href ? ( - - - {value} - - - - } - content={href} - /> + ) : ( {value} )} @@ -64,3 +54,40 @@ export function LabelValueStack({
); } + +type ValueButtonStackProps = { + value: React.ReactNode; + href: string; + variant?: keyof typeof variations; +}; + +function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackProps) { + const variation = variations[variant]; + + const isExternalUrl = href.startsWith("http"); + + if (!isExternalUrl) { + return ( + + + {value} + + + ); + } + + return ( + + + {value} + + + + } + content={href} + /> + ); +} diff --git a/apps/webapp/app/components/primitives/NamedIcon.tsx b/apps/webapp/app/components/primitives/NamedIcon.tsx index 523fade79..d776a1d88 100644 --- a/apps/webapp/app/components/primitives/NamedIcon.tsx +++ b/apps/webapp/app/components/primitives/NamedIcon.tsx @@ -59,16 +59,18 @@ import { import { CompanyIcon, hasIcon } from "@trigger.dev/companyicons"; import { ActivityIcon, HourglassIcon } from "lucide-react"; import { DynamicTriggerIcon } from "~/assets/icons/DynamicTriggerIcon"; +import { EndpointIcon } from "~/assets/icons/EndpointIcon"; import { ErrorIcon } from "~/assets/icons/ErrorIcon"; +import { OneTreeIcon } from "~/assets/icons/OneTreeIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { SaplingIcon } from "~/assets/icons/SaplingIcon"; import { ScheduleIcon } from "~/assets/icons/ScheduleIcon"; +import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon"; import { WebhookIcon } from "~/assets/icons/WebhookIcon"; import { cn } from "~/utils/cn"; +import { tablerIcons } from "~/utils/tablerIcons"; import { LogoIcon } from "../LogoIcon"; import { Spinner } from "./Spinner"; -import { SaplingIcon } from "~/assets/icons/SaplingIcon"; -import { TwoTreesIcon } from "~/assets/icons/TwoTreesIcon"; -import { OneTreeIcon } from "~/assets/icons/OneTreeIcon"; -import { tablerIcons } from "~/utils/tablerIcons"; import tablerSpritePath from "./tabler-sprite.svg"; const icons = { @@ -127,7 +129,9 @@ const icons = { "invite-member": (className: string) => ( ), - job: (className: string) => , + job: (className: string) => ( + + ), key: (className: string) => , lightbulb: (className: string) => , "clipboard-checked": (className: string) => ( @@ -183,6 +187,11 @@ const icons = { ), webhook: (className: string) => , + endpoint: (className: string) => , + "http-endpoint": (className: string) => ( + + ), + runs: (className: string) => , }; export type IconNames = keyof typeof icons; diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index e3fab7ed7..d38d35c4f 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -6,6 +6,9 @@ import { Paragraph } from "./Paragraph"; import { cn } from "~/utils/cn"; import { NamedIcon } from "./NamedIcon"; import { Tabs, TabsProps } from "./Tabs"; +import { Icon, RenderIcon } from "./Icon"; +import { Button, LinkButton } from "./Buttons"; +import { ArrowUpRightIcon } from "@heroicons/react/20/solid"; type WithChildren = { children: React.ReactNode; @@ -13,7 +16,7 @@ type WithChildren = { export function PageHeader({ children, hideBorder }: WithChildren & { hideBorder?: boolean }) { return ( -
+
{children}
); @@ -24,6 +27,7 @@ export function PageTitleRow({ children }: WithChildren) { } type PageTitleProps = { + icon?: RenderIcon; title: string; backButton?: { to: string; @@ -31,7 +35,7 @@ type PageTitleProps = { }; }; -export function PageTitle({ title, backButton }: PageTitleProps) { +export function PageTitle({ icon, title, backButton }: PageTitleProps) { return (
{backButton && ( @@ -48,7 +52,10 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
)} - {title} + + {icon && } + {title} +
); } @@ -89,20 +96,43 @@ export function PageInfoProperty({ icon, label, value, + to, }: { icon?: string | React.ReactNode; label?: string; - value: React.ReactNode; + value?: React.ReactNode; + to?: string; +}) { + if (to === undefined) { + return ; + } + + return ( + + + + ); +} + +function PageInfoPropertyContent({ + icon, + label, + value, +}: { + icon?: string | React.ReactNode; + label?: string; + value?: React.ReactNode; }) { return (
{icon && typeof icon === "string" ? : icon} {label && ( - {label}: + {label} + {value && ":"} )} - {value} + {value && {value}}
); } diff --git a/apps/webapp/app/components/primitives/Paragraph.tsx b/apps/webapp/app/components/primitives/Paragraph.tsx index 3abaaaded..8a3b6b386 100644 --- a/apps/webapp/app/components/primitives/Paragraph.tsx +++ b/apps/webapp/app/components/primitives/Paragraph.tsx @@ -51,6 +51,7 @@ const paragraphVariants = { text: "font-sans text-xxs font-normal text-bright", spacing: "mb-1", }, + "extra-extra-small/caps": { text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed", spacing: "mb-1", @@ -59,6 +60,10 @@ const paragraphVariants = { text: "font-sans text-xxs uppercase tracking-wider font-normal text-bright", spacing: "mb-1", }, + "extra-extra-small/dimmed/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-dimmed", + spacing: "mb-1", + }, }; export type ParagraphVariant = keyof typeof paragraphVariants; diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 357b1c54e..cb374e75f 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -3,12 +3,11 @@ import * as React from "react"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { cn } from "~/utils/cn"; -import { Paragraph } from "./Paragraph"; +import { Paragraph, ParagraphVariant } from "./Paragraph"; import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid"; -import { LinkButton } from "./Buttons"; +import { ButtonContentPropsType, LinkButton } from "./Buttons"; const Popover = PopoverPrimitive.Root; - const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< @@ -22,19 +21,27 @@ const PopoverContent = React.forwardRef< sideOffset={sideOffset} avoidCollisions={true} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} - style={{ maxHeight: "var(--radix-popover-content-available-height)" }} + style={{ + maxHeight: "var(--radix-popover-content-available-height)", + }} {...props} /> )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -function PopoverSectionHeader({ title }: { title: string }) { +function PopoverSectionHeader({ + title, + variant = "extra-extra-small/dimmed/caps", +}: { + title: string; + variant?: ParagraphVariant; +}) { return ( - + {title} ); @@ -45,28 +52,33 @@ function PopoverMenuItem({ icon, title, isSelected, + variant = { variant: "small-menu-item" }, + leadingIconClassName, }: { to: string; - icon: string; + icon: string | React.ComponentType; title: React.ReactNode; isSelected?: boolean; + variant?: ButtonContentPropsType; + leadingIconClassName?: string; }) { return ( {title} ); } -function PopoverArrowTrigger({ +function PopoverCustomTrigger({ isOpen, children, className, @@ -76,15 +88,50 @@ function PopoverArrowTrigger({ - + {children} + + ); +} + +function PopoverArrowTrigger({ + isOpen, + children, + fullWidth = false, + overflowHidden = false, + className, + ...props +}: { + isOpen?: boolean; + fullWidth?: boolean; + overflowHidden?: boolean; +} & React.ComponentPropsWithoutRef) { + return ( + + {children} ); @@ -113,7 +160,8 @@ export { PopoverTrigger, PopoverContent, PopoverSectionHeader, + PopoverCustomTrigger, PopoverArrowTrigger, - PopoverMenuItem, PopoverVerticalEllipseTrigger, + PopoverMenuItem, }; diff --git a/apps/webapp/app/components/primitives/Sheet.tsx b/apps/webapp/app/components/primitives/Sheet.tsx index 5432f8007..844c29ca1 100644 --- a/apps/webapp/app/components/primitives/Sheet.tsx +++ b/apps/webapp/app/components/primitives/Sheet.tsx @@ -51,7 +51,7 @@ const SheetOverlay = React.forwardRef< SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; const sheetVariants = cva( - "fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-uiBorder", + "fixed z-50 scale-100 gap-4 bg-midnight-900 shadow-lg shadow-white/10 opacity-100 border-l border-ui-border", { variants: { position: { @@ -154,7 +154,7 @@ const SheetContent = React.forwardRef< {...props} >
-
+
Close @@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes) => (
) => (
-
{children}
+
{children}
); diff --git a/apps/webapp/app/components/primitives/Spinner.tsx b/apps/webapp/app/components/primitives/Spinner.tsx index fbe430848..b8593287c 100644 --- a/apps/webapp/app/components/primitives/Spinner.tsx +++ b/apps/webapp/app/components/primitives/Spinner.tsx @@ -5,7 +5,7 @@ export function Spinner({ color = "blue", }: { className?: string; - color?: "blue" | "white"; + color?: "blue" | "white" | "muted"; }) { const colors = { blue: { @@ -16,6 +16,10 @@ export function Spinner({ light: "rgba(255, 255, 255, 0.4)", dark: "rgba(255, 255, 255)", }, + muted: { + light: "#1C2433", + dark: "#3C4B62", + }, }; const currentColor = colors[color]; diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index c7240256a..9c02814d8 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -16,7 +16,7 @@ export const Table = forwardRef( return (
return (
{children} @@ -55,7 +55,7 @@ type TableBodyProps = { export const TableBody = forwardRef( ({ className, children }, ref) => { return ( - + {children} ); @@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef +
{tabs.map((tab, index) => ( {({ isActive, isPending }) => ( @@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) { {tab.label} {isActive || isPending ? ( - + ) : (
)} diff --git a/apps/webapp/app/components/run/RunCard.tsx b/apps/webapp/app/components/run/RunCard.tsx index 1731622c2..e3bc1e998 100644 --- a/apps/webapp/app/components/run/RunCard.tsx +++ b/apps/webapp/app/components/run/RunCard.tsx @@ -36,7 +36,7 @@ export function RunPanel({ ? "border-slate-850" : "border-slate-900", onClick && "cursor-pointer", - onClick && !selected && "hover:border-green-500/30", + onClick && !selected && "hover:border-slate-500/30", className )} onClick={() => onClick && onClick()} diff --git a/apps/webapp/app/components/run/RunOverview.tsx b/apps/webapp/app/components/run/RunOverview.tsx index f9086d3c6..96f15f53a 100644 --- a/apps/webapp/app/components/run/RunOverview.tsx +++ b/apps/webapp/app/components/run/RunOverview.tsx @@ -1,6 +1,7 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { BoltIcon, ForwardIcon } from "@heroicons/react/24/solid"; +import { PlayIcon } from "@heroicons/react/20/solid"; +import { BoltIcon } from "@heroicons/react/24/solid"; import { Form, Outlet, @@ -159,6 +160,17 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
+ {run.status === "SUCCESS" && + (run.tasks.length === 0 || run.tasks.every((t) => t.noop)) && ( + + This Run completed but it did not use any Tasks – this can cause unpredictable + results. Read the docs to view the solution. + + )} Trigger ) : ( run.output === null && ( - This Run returned nothing. + + This Run returned nothing. + ) )} @@ -262,7 +276,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps {/* Detail view */}
- Detail + Details {selectedId ? : Select a task or trigger}
@@ -313,7 +327,8 @@ function RerunPopover({ const [form, { successRedirect }] = useForm({ id: "rerun", - lastSubmission, + // TODO: type this + lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema }); }, @@ -326,50 +341,52 @@ function RerunPopover({ Rerun Job - + {environmentType === "PRODUCTION" && ( - - This will rerun this Job in your Production environment. - +
+ + This will rerun this Job in your Production environment. + +
)} -
-
+
+
+ + Start a brand new Job run with the same Trigger data as this one. This will re-do + every Task. + - - - Start a brand new job run with the same Trigger data as this one. This will re-do - every task. -
{status === "FAILED" && ( -
+
+ + Continue running this Job run from where it left off. This will skip any Task that + has already been completed. + - - - Continue running this job run from where it left off. This will skip any task that - has already been completed. -
)}
@@ -386,7 +403,8 @@ export function CancelRun({ runId }: { runId: string }) { const [form, { redirectUrl }] = useForm({ id: "cancel-run", - lastSubmission, + // TODO: type this + lastSubmission: lastSubmission as any, onValidate({ formData }) { return parse(formData, { schema: cancelSchema }); }, diff --git a/apps/webapp/app/components/run/TaskDetail.tsx b/apps/webapp/app/components/run/TaskDetail.tsx index 9a85df317..bb4c9ec86 100644 --- a/apps/webapp/app/components/run/TaskDetail.tsx +++ b/apps/webapp/app/components/run/TaskDetail.tsx @@ -28,12 +28,24 @@ import { } from "../primitives/Table"; import { TaskAttemptStatusLabel } from "./TaskAttemptStatus"; import { TaskStatusIcon } from "./TaskStatus"; -import { ClientOnly } from "remix-utils"; +import { ClientOnly } from "remix-utils/client-only"; import { Spinner } from "../primitives/Spinner"; import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route"; export function TaskDetail({ task }: { task: DetailedTask }) { - const { name, description, icon, status, params, properties, output, style, attempts } = task; + const { + name, + description, + icon, + status, + params, + properties, + output, + outputIsUndefined, + style, + attempts, + noop, + } = task; const startedAt = task.startedAt ? new Date(task.startedAt) : undefined; const completedAt = task.completedAt ? new Date(task.completedAt) : undefined; @@ -140,16 +152,18 @@ export function TaskDetail({ task }: { task: DetailedTask }) { No input )}
-
- Output - {output ? ( - }> - {() => } - - ) : ( - No output - )} -
+ {!noop && ( +
+ Output + {output && !outputIsUndefined ? ( + }> + {() => } + + ) : ( + No output + )} +
+ )} ); diff --git a/apps/webapp/app/components/runs/RunsTable.tsx b/apps/webapp/app/components/runs/RunsTable.tsx index 7f185e3c3..879bf3505 100644 --- a/apps/webapp/app/components/runs/RunsTable.tsx +++ b/apps/webapp/app/components/runs/RunsTable.tsx @@ -1,14 +1,10 @@ import { StopIcon } from "@heroicons/react/24/outline"; import { CheckIcon } from "@heroicons/react/24/solid"; -import { useJob } from "~/hooks/useJob"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { useProject } from "~/hooks/useProject"; -import { RunList } from "~/presenters/RunListPresenter.server"; +import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database"; import { formatDuration } from "~/utils"; -import { JobForPath, OrgForPath, ProjectForPath, jobRunDashboardPath } from "~/utils/pathBuilder"; import { EnvironmentLabel } from "../environments/EnvironmentLabel"; -import { Callout } from "../primitives/Callout"; import { DateTime } from "../primitives/DateTime"; +import { Paragraph } from "../primitives/Paragraph"; import { Spinner } from "../primitives/Spinner"; import { Table, @@ -21,7 +17,6 @@ import { TableRow, } from "../primitives/Table"; import { RunStatus } from "./RunStatuses"; -import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database"; type RunTableItem = { id: string; @@ -129,9 +124,7 @@ export function RunsTable({ function NoRuns({ title }: { title: string }) { return (
- - {title} - + {title}
); } diff --git a/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx new file mode 100644 index 000000000..46160052c --- /dev/null +++ b/apps/webapp/app/components/runs/WebhookDeliveryRunsTable.tsx @@ -0,0 +1,126 @@ +import { StopIcon } from "@heroicons/react/24/outline"; +import { CheckIcon } from "@heroicons/react/24/solid"; +import { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { formatDuration } from "~/utils"; +import { EnvironmentLabel } from "../environments/EnvironmentLabel"; +import { DateTime } from "../primitives/DateTime"; +import { Paragraph } from "../primitives/Paragraph"; +import { Spinner } from "../primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "../primitives/Table"; +import { RunStatus } from "./RunStatuses"; + +type RunTableItem = { + id: string; + number: number; + environment: { + type: RuntimeEnvironmentType; + }; + error: string | null; + createdAt: Date | null; + deliveredAt: Date | null; + verified: boolean; +}; + +type RunsTableProps = { + total: number; + hasFilters: boolean; + runs: RunTableItem[]; + isLoading?: boolean; + runsParentPath: string; +}; + +export function WebhookDeliveryRunsTable({ + total, + hasFilters, + runs, + isLoading = false, + runsParentPath, +}: RunsTableProps) { + return ( +
+ + + Run + Env + Status + Last Error + Started + Duration + Verified + Created at + + + + {total === 0 && !hasFilters ? ( + + + + ) : runs.length === 0 ? ( + + + + ) : ( + runs.map((run) => { + return ( + + #{run.number} + + + + + + + {run.error?.slice(0, 30) ?? "–"} + {run.createdAt ? : "–"} + + {formatDuration(run.createdAt, run.deliveredAt, { + style: "short", + })} + + + {run.verified ? ( + + ) : ( + + )} + + {run.createdAt ? : "–"} + + ); + }) + )} + {isLoading && ( + + Loading… + + )} + +
+ ); +} +function NoRuns({ title }: { title: string }) { + return ( +
+ {title} +
+ ); +} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 171a5b79d..8c4959390 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -3,6 +3,7 @@ import invariant from "tiny-invariant"; import { z } from "zod"; import { logger } from "./services/logger.server"; import { env } from "./env.server"; +import { singleton } from "./utils/singleton"; export type PrismaTransactionClient = Omit< PrismaClient, @@ -47,7 +48,7 @@ export async function $transaction( return await (prisma as PrismaClient).$transaction(fn, options); } catch (error) { if (isPrismaKnownError(error)) { - logger.debug("prisma.$transaction error", { + logger.error("prisma.$transaction error", { code: error.code, meta: error.meta, stack: error.stack, @@ -66,24 +67,7 @@ export async function $transaction( export { Prisma }; -let prisma: PrismaClient; - -declare global { - var __db__: PrismaClient; -} - -// this is needed because in development we don't want to restart -// the server with every change, but we want to make sure we don't -// create a new connection to the DB with every change either. -// in production we'll have a single connection to the DB. -if (process.env.NODE_ENV === "production") { - prisma = getClient(); -} else { - if (!global.__db__) { - global.__db__ = getClient(); - } - prisma = global.__db__; -} +export const prisma = singleton("prisma", getClient); function getClient() { const { DATABASE_URL } = process.env; @@ -143,7 +127,6 @@ function getClient() { return client; } -export { prisma }; export type { PrismaClient } from "@trigger.dev/database"; export const PrismaErrorSchema = z.object({ diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index eddc34ed4..85dbbaa87 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -1,6 +1,9 @@ import { H } from "@highlight-run/node"; -import type { DataFunctionArgs, EntryContext, Headers } from "@remix-run/node"; // or cloudflare/deno -import { Response } from "@remix-run/node"; // or cloudflare/deno +import { + createReadableStreamFromReadable, + type DataFunctionArgs, + type EntryContext, +} from "@remix-run/node"; // or cloudflare/deno import { RemixServer } from "@remix-run/react"; import { parseAcceptLanguage } from "intl-parse-accept-language"; import isbot from "isbot"; @@ -13,6 +16,8 @@ import { OperatingSystemPlatform, } from "./components/primitives/OperatingSystemProvider"; import { env } from "./env.server"; +import { getSharedSqsEventConsumer } from "./services/events/sqsEventConsumer"; +import { singleton } from "./utils/singleton"; const ABORT_DELAY = 30000; @@ -36,7 +41,7 @@ export default function handleRequest( // response to render before sending it to the client. This // ensures that bots can see the full page content. if (isbot(request.headers.get("user-agent"))) { - return serveTheBots( + return handleBotRequest( request, responseStatusCode, responseHeaders, @@ -46,7 +51,7 @@ export default function handleRequest( ); } - return serveBrowsers( + return handleBrowserRequest( request, responseStatusCode, responseHeaders, @@ -56,7 +61,7 @@ export default function handleRequest( ); } -function serveTheBots( +function handleBotRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, @@ -65,76 +70,97 @@ function serveTheBots( platform: OperatingSystemPlatform ) { return new Promise((resolve, reject) => { + let shellRendered = false; const { pipe, abort } = renderToPipeableStream( - + , , { - // Use onAllReady to wait for the entire document to be ready onAllReady() { - responseHeaders.set("Content-Type", "text/html; charset=utf-8"); - let body = new PassThrough(); - pipe(body); - resolve( - new Response(body, { - status: responseStatusCode, - headers: responseHeaders, - }) - ); - }, - onShellError(err: unknown) { - reject(err); - }, - } - ); - setTimeout(abort, ABORT_DELAY); - }); -} + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); -function serveBrowsers( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - locales: string[], - platform: OperatingSystemPlatform -) { - return new Promise((resolve, reject) => { - let didError = false; - let shellReady = false; - const { pipe, abort } = renderToPipeableStream( - - - - - , - { - // use onShellReady to wait until a suspense boundary is triggered - onShellReady() { - shellReady = true; - responseHeaders.set("Content-Type", "text/html; charset=utf-8"); - let body = new PassThrough(); - pipe(body); resolve( - new Response(body, { - status: didError ? 500 : responseStatusCode, + new Response(stream, { headers: responseHeaders, + status: responseStatusCode, }) ); + + pipe(body); }, - onShellError(err: unknown) { - reject(err); + onShellError(error: unknown) { + reject(error); }, onError(error: unknown) { - didError = true; - if (shellReady) { - logError(error, request); + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); } }, } ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + locales: string[], + platform: OperatingSystemPlatform +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + + + + + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + setTimeout(abort, ABORT_DELAY); }); } @@ -172,3 +198,5 @@ function logError(error: unknown, request?: Request) { console.log("⚠️ see: https://trigger.dev/docs/documentation/guides/self-hosting/graphile-migration"); } } + +const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 4e229834c..e3f6d62ec 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { SecretStoreOptionsSchema } from "./services/secrets/secretStore.server"; +import { isValidRegex } from "./utils/regex"; const EnvironmentSchema = z.object({ NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]), @@ -10,6 +11,10 @@ const EnvironmentSchema = z.object({ SESSION_SECRET: z.string(), MAGIC_LINK_SECRET: z.string(), ENCRYPTION_KEY: z.string(), + WHITELISTED_EMAILS: z + .string() + .refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.") + .optional(), REMIX_APP_PORT: z.string().optional(), LOGIN_ORIGIN: z.string().default("http://localhost:3030"), APP_ORIGIN: z.string().default("http://localhost:3030"), @@ -40,7 +45,20 @@ const EnvironmentSchema = z.object({ EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), WORKER_ENABLED: z.string().default("true"), EXECUTION_WORKER_ENABLED: z.string().default("true"), + TASK_OPERATION_WORKER_ENABLED: z.string().default("true"), + TASK_OPERATION_WORKER_CONCURRENCY: z.coerce.number().int().default(10), + TASK_OPERATION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000), GRACEFUL_SHUTDOWN_TIMEOUT: z.coerce.number().int().default(60000), + /** Optional. Only used if you use the apps/proxy */ + AWS_SQS_REGION: z.string().optional(), + /** Optional. Only used if you use the apps/proxy */ + AWS_SQS_ACCESS_KEY_ID: z.string().optional(), + /** Optional. Only used if you use the apps/proxy */ + AWS_SQS_SECRET_ACCESS_KEY: z.string().optional(), + /** Optional. Only used if you use the apps/proxy */ + AWS_SQS_QUEUE_URL: z.string().optional(), + AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(10), + DISABLE_SSE: z.string().optional(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/hooks/useEnvironments.ts b/apps/webapp/app/hooks/useEnvironments.ts index 20601bcd9..08ebaf457 100644 --- a/apps/webapp/app/hooks/useEnvironments.ts +++ b/apps/webapp/app/hooks/useEnvironments.ts @@ -1,17 +1,17 @@ -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { MatchedProject, useOptionalProject } from "./useProject"; import { useUser } from "./useUser"; export type ProjectJobEnvironment = MatchedProject["environments"][number]; -export function useEnvironments(matches?: RouteMatch[]) { +export function useEnvironments(matches?: UIMatch[]) { const project = useOptionalProject(matches); if (!project) return; return project.environments; } -export function useDevEnvironment(matches?: RouteMatch[]) { +export function useDevEnvironment(matches?: UIMatch[]) { const user = useUser(); const environments = useEnvironments(matches); if (!environments) return; @@ -21,7 +21,7 @@ export function useDevEnvironment(matches?: RouteMatch[]) { ); } -export function useProdEnvironment(matches?: RouteMatch[]) { +export function useProdEnvironment(matches?: UIMatch[]) { const environments = useEnvironments(matches); if (!environments) return; diff --git a/apps/webapp/app/hooks/useEventSource.tsx b/apps/webapp/app/hooks/useEventSource.tsx new file mode 100644 index 000000000..8a4c9a430 --- /dev/null +++ b/apps/webapp/app/hooks/useEventSource.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +type EventSourceOptions = { + init?: EventSourceInit; + event?: string; + disabled?: boolean; +}; + +/** + * Subscribe to an event source and return the latest event. + * @param url The URL of the event source to connect to + * @param options The options to pass to the EventSource constructor + * @returns The last event received from the server + */ +export function useEventSource( + url: string | URL, + { event = "message", init, disabled }: EventSourceOptions = {} +) { + const [data, setData] = useState(null); + + useEffect(() => { + if (disabled) { + return; + } + + const eventSource = new EventSource(url, init); + eventSource.addEventListener(event ?? "message", handler); + + // rest data if dependencies change + setData(null); + + function handler(event: MessageEvent) { + setData(event.data || "UNKNOWN_EVENT_DATA"); + } + + return () => { + eventSource.removeEventListener(event ?? "message", handler); + eventSource.close(); + }; + }, [url, event, init, disabled]); + + return data; +} diff --git a/apps/webapp/app/hooks/useFilterJobs.ts b/apps/webapp/app/hooks/useFilterJobs.ts index 950e19a43..9d220952e 100644 --- a/apps/webapp/app/hooks/useFilterJobs.ts +++ b/apps/webapp/app/hooks/useFilterJobs.ts @@ -1,4 +1,4 @@ -import { ProjectJob } from "./useJobs"; +import { ProjectJob } from "~/presenters/JobListPresenter.server"; import { useTextFilter } from "./useTextFilter"; import { useToggleFilter } from "./useToggleFilter"; diff --git a/apps/webapp/app/hooks/useIntegrationClient.tsx b/apps/webapp/app/hooks/useIntegrationClient.tsx index 0540f2619..bfe9fad24 100644 --- a/apps/webapp/app/hooks/useIntegrationClient.tsx +++ b/apps/webapp/app/hooks/useIntegrationClient.tsx @@ -1,21 +1,21 @@ -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { UseDataFunctionReturn } from "remix-typedjson"; import invariant from "tiny-invariant"; -import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam/route"; +import type { loader } from "~/routes/_app.orgs.$organizationSlug.integrations_.$clientParam/route"; import { useTypedMatchesData } from "./useTypedMatchData"; export type MatchedClient = UseDataFunctionReturn["client"]; -export function useOptionalIntegrationClient(matches?: RouteMatch[]) { +export function useOptionalIntegrationClient(matches?: UIMatch[]) { const routeMatch = useTypedMatchesData({ - id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam", + id: "routes/_app.orgs.$organizationSlug.integrations_.$clientParam", matches, }); return routeMatch?.client; } -export function useIntegrationClient(matches?: RouteMatch[]) { +export function useIntegrationClient(matches?: UIMatch[]) { const integration = useOptionalIntegrationClient(matches); invariant(integration, "Integration must be defined"); return integration; diff --git a/apps/webapp/app/hooks/useIsProjectChildPage.ts b/apps/webapp/app/hooks/useIsOrgChildPage.ts similarity index 51% rename from apps/webapp/app/hooks/useIsProjectChildPage.ts rename to apps/webapp/app/hooks/useIsOrgChildPage.ts index a21692a28..685630ccc 100644 --- a/apps/webapp/app/hooks/useIsProjectChildPage.ts +++ b/apps/webapp/app/hooks/useIsOrgChildPage.ts @@ -1,11 +1,11 @@ -import { RouteMatch, useMatches } from "@remix-run/react"; +import { UIMatch, useMatches } from "@remix-run/react"; -export function useIsProjectChildPage(matches?: RouteMatch[]) { +export function useIsOrgChildPage(matches?: UIMatch[]) { if (!matches) { matches = useMatches(); } return matches.some((matchData) => { - return matchData.id.startsWith("routes/_app.orgs.$organizationSlug.projects.$projectParam"); + return matchData.id.startsWith("routes/_app.orgs.$organizationSlug"); }); } diff --git a/apps/webapp/app/hooks/useJob.tsx b/apps/webapp/app/hooks/useJob.tsx index e381d1535..7064e8f30 100644 --- a/apps/webapp/app/hooks/useJob.tsx +++ b/apps/webapp/app/hooks/useJob.tsx @@ -2,27 +2,28 @@ import { UseDataFunctionReturn } from "remix-typedjson"; import invariant from "tiny-invariant"; import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route"; import { useChanged } from "./useChanged"; -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { useTypedMatchesData } from "./useTypedMatchData"; export type MatchedJob = UseDataFunctionReturn["job"]; export const jobMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam"; -export function useOptionalJob(matches?: RouteMatch[]) { + +export function useOptionalJob(matches?: UIMatch[]) { const routeMatch = useTypedMatchesData({ id: jobMatchId, matches, }); - if (!routeMatch || !routeMatch.job) { + if (!routeMatch) { return undefined; } - return routeMatch.projectJobs.find((j) => j.id === routeMatch.job.id); + return routeMatch.job; } -export function useJob(matches?: RouteMatch[]) { +export function useJob(matches?: UIMatch[]) { const job = useOptionalJob(matches); invariant(job, "Job must be defined"); return job; diff --git a/apps/webapp/app/hooks/useJobs.tsx b/apps/webapp/app/hooks/useJobs.tsx deleted file mode 100644 index 80115f12e..000000000 --- a/apps/webapp/app/hooks/useJobs.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { UseDataFunctionReturn } from "remix-typedjson"; -import invariant from "tiny-invariant"; -import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam/route"; -import { RouteMatch } from "@remix-run/react"; -import { useTypedMatchesData } from "./useTypedMatchData"; - -export type ProjectJob = UseDataFunctionReturn["projectJobs"][number]; - -export const jobsMatchId = - "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam"; - -// This is only used in the JobsMenu component, which is the breadcrumb job list dropdown. -// This dropdown is only shown once you have selected a job, so we can assume that -// the route above has loaded and we can use the data from it. -export function useOptionalJobs(matches?: RouteMatch[]) { - const routeMatch = useTypedMatchesData({ - id: jobsMatchId, - matches, - }); - - return routeMatch?.projectJobs; -} - -export function useJobs(matches?: RouteMatch[]) { - const jobs = useOptionalJobs(matches); - invariant(jobs, "Jobs must be defined"); - return jobs; -} diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index 6a42d60e3..ace3e5da5 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -1,29 +1,29 @@ import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson"; import invariant from "tiny-invariant"; import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route"; -import type { loader as appLoader } from "~/routes/_app/route"; import { hydrateObject, useMatchesData } from "~/utils"; import { useChanged } from "./useChanged"; -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { useTypedMatchesData } from "./useTypedMatchData"; -export type MatchedOrganization = UseDataFunctionReturn["organizations"][number]; +export type MatchedOrganization = UseDataFunctionReturn["organizations"][number]; +export const organizationMatchId = "routes/_app.orgs.$organizationSlug"; -export function useOptionalOrganizations(matches?: RouteMatch[]) { - const data = useTypedMatchesData({ - id: "routes/_app", +export function useOptionalOrganizations(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", matches, }); return data?.organizations; } -export function useOrganizations(matches?: RouteMatch[]) { +export function useOrganizations(matches?: UIMatch[]) { const orgs = useOptionalOrganizations(matches); invariant(orgs, "No organizations found in loader."); return orgs; } -export function useOptionalOrganization(matches?: RouteMatch[]) { +export function useOptionalOrganization(matches?: UIMatch[]) { const orgs = useOptionalOrganizations(matches); const org = useTypedMatchesData({ id: "routes/_app.orgs.$organizationSlug", @@ -37,13 +37,13 @@ export function useOptionalOrganization(matches?: RouteMatch[]) { return orgs.find((o) => o.id === org.organization.id); } -export function useOrganization(matches?: RouteMatch[]) { +export function useOrganization(matches?: UIMatch[]) { const org = useOptionalOrganization(matches); invariant(org, "No organization found in loader."); return org; } -export function useIsNewOrganizationPage(matches?: RouteMatch[]): boolean { +export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean { const data = useTypedMatchesData({ id: "routes/_app.orgs.new", matches, diff --git a/apps/webapp/app/hooks/useProject.tsx b/apps/webapp/app/hooks/useProject.tsx index 58125ee1d..c223b8a95 100644 --- a/apps/webapp/app/hooks/useProject.tsx +++ b/apps/webapp/app/hooks/useProject.tsx @@ -1,4 +1,4 @@ -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { UseDataFunctionReturn } from "remix-typedjson"; import invariant from "tiny-invariant"; import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route"; @@ -9,7 +9,7 @@ export type MatchedProject = UseDataFunctionReturn["project"]; export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam"; -export function useOptionalProject(matches?: RouteMatch[]) { +export function useOptionalProject(matches?: UIMatch[]) { const routeMatch = useTypedMatchesData({ id: projectMatchId, matches, @@ -18,7 +18,7 @@ export function useOptionalProject(matches?: RouteMatch[]) { return routeMatch?.project; } -export function useProject(matches?: RouteMatch[]) { +export function useProject(matches?: UIMatch[]) { const project = useOptionalProject(matches); invariant(project, "Project must be defined"); return project; diff --git a/apps/webapp/app/hooks/useProjectSetupComplete.ts b/apps/webapp/app/hooks/useProjectSetupComplete.ts index 7f0e64ab9..cbfb78b5c 100644 --- a/apps/webapp/app/hooks/useProjectSetupComplete.ts +++ b/apps/webapp/app/hooks/useProjectSetupComplete.ts @@ -1,9 +1,9 @@ import { useEffect } from "react"; -import { useEventSource } from "remix-utils"; import { projectPath, projectStreamingPath } from "~/utils/pathBuilder"; import { useProject } from "./useProject"; import { useOrganization } from "./useOrganizations"; import { useNavigate } from "@remix-run/react"; +import { useEventSource } from "./useEventSource"; export function useProjectSetupComplete() { const project = useProject(); diff --git a/apps/webapp/app/hooks/useRun.ts b/apps/webapp/app/hooks/useRun.ts index 2032c2f39..40c2516c9 100644 --- a/apps/webapp/app/hooks/useRun.ts +++ b/apps/webapp/app/hooks/useRun.ts @@ -1,4 +1,4 @@ -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { UseDataFunctionReturn } from "remix-typedjson"; import invariant from "tiny-invariant"; import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route"; @@ -7,7 +7,7 @@ import { useTypedMatchesData } from "./useTypedMatchData"; export type MatchedRun = UseDataFunctionReturn["run"]; -export function useOptionalRun(matches?: RouteMatch[]) { +export function useOptionalRun(matches?: UIMatch[]) { const project = useOptionalProject(matches); const routeMatch = useTypedMatchesData({ id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam", @@ -21,7 +21,7 @@ export function useOptionalRun(matches?: RouteMatch[]) { return routeMatch.run; } -export function useRun(matches?: RouteMatch[]) { +export function useRun(matches?: UIMatch[]) { const run = useOptionalRun(matches); invariant(run, "Run must be present"); return run; diff --git a/apps/webapp/app/hooks/useTypedMatchData.ts b/apps/webapp/app/hooks/useTypedMatchData.ts index f0c3e6b14..fe5077717 100644 --- a/apps/webapp/app/hooks/useTypedMatchData.ts +++ b/apps/webapp/app/hooks/useTypedMatchData.ts @@ -1,4 +1,4 @@ -import { RouteMatch, useMatches } from "@remix-run/react"; +import { UIMatch, useMatches } from "@remix-run/react"; import { RemixSerializedType, UseDataFunctionReturn, deserializeRemix } from "remix-typedjson"; type AppData = any; @@ -8,7 +8,7 @@ function useTypedDataFromMatches({ matches, }: { id: string; - matches: RouteMatch[]; + matches: UIMatch[]; }): UseDataFunctionReturn | undefined { const match = matches.find((m) => m.id === id); return useTypedMatchData(match); @@ -19,7 +19,7 @@ export function useTypedMatchesData({ matches, }: { id: string; - matches?: RouteMatch[]; + matches?: UIMatch[]; }): UseDataFunctionReturn | undefined { if (!matches) { matches = useMatches(); @@ -29,7 +29,7 @@ export function useTypedMatchesData({ } export function useTypedMatchData( - match: RouteMatch | undefined + match: UIMatch | undefined ): UseDataFunctionReturn | undefined { if (!match) { return undefined; diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts index ccd942778..98ad40fc0 100644 --- a/apps/webapp/app/hooks/useUser.ts +++ b/apps/webapp/app/hooks/useUser.ts @@ -1,11 +1,11 @@ import type { User } from "~/models/user.server"; import { useMatchesData } from "~/utils"; import { useChanged } from "./useChanged"; -import { RouteMatch } from "@remix-run/react"; +import { UIMatch } from "@remix-run/react"; import { useTypedMatchesData } from "./useTypedMatchData"; import { loader } from "~/root"; -export function useOptionalUser(matches?: RouteMatch[]): User | undefined { +export function useOptionalUser(matches?: UIMatch[]): User | undefined { const routeMatch = useTypedMatchesData({ id: "root", matches, @@ -14,7 +14,7 @@ export function useOptionalUser(matches?: RouteMatch[]): User | undefined { return routeMatch?.user ?? undefined; } -export function useUser(matches?: RouteMatch[]): User { +export function useUser(matches?: UIMatch[]): User { const maybeUser = useOptionalUser(matches); if (!maybeUser) { throw new Error( diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts new file mode 100644 index 000000000..a54fdae9a --- /dev/null +++ b/apps/webapp/app/models/api-key.server.ts @@ -0,0 +1,101 @@ +import { RuntimeEnvironmentType, type RuntimeEnvironment } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { customAlphabet } from "nanoid"; + +const apiKeyId = customAlphabet( + "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + 12 +); + +type RegenerateAPIKeyInput = { + userId: string; + environmentId: string; +}; + +export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) { + const environment = await prisma.runtimeEnvironment.findUnique({ + where: { + id: environmentId, + }, + include: { + organization: true, + project: true, + }, + }); + + if (!environment) { + throw new Error("Environment does not exist"); + } + + // check if the user is part of the org + const organization = await prisma.organization.findFirst({ + where: { + id: environment.organization.id, + members: { some: { userId } }, + }, + }); + + if (!organization) { + throw new Error("User does not have permission to regenerate API key"); + } + + // check if it is the user's dev environment + if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) { + if (!environment.orgMemberId) { + throw new Error("User does not have permission to regenerate API key"); + } + + const orgMember = await prisma.orgMember.findFirst({ + where: { + organizationId: organization.id, + userId: userId, + id: environment.orgMemberId, + }, + }); + + if (!orgMember) { + throw new Error("User does not have permission to regenerate API key"); + } + } + + // generate and store new keys + const newApiKey = createApiKeyForEnv(environment.type); + const newPkApiKey = createPkApiKeyForEnv(environment.type); + + const updatedEnviroment = await prisma.runtimeEnvironment.update({ + data: { + apiKey: newApiKey, + pkApiKey: newPkApiKey, + }, + where: { + id: environmentId, + }, + }); + + return updatedEnviroment; +} + +export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) { + return `tr_${envSlug(envType)}_${apiKeyId(20)}`; +} + +export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) { + return `pk_${envSlug(envType)}_${apiKeyId(20)}`; +} + +export function envSlug(environmentType: RuntimeEnvironment["type"]) { + switch (environmentType) { + case "DEVELOPMENT": { + return "dev"; + } + case "PRODUCTION": { + return "prod"; + } + case "STAGING": { + return "stg"; + } + case "PREVIEW": { + return "prev"; + } + } +} diff --git a/apps/webapp/app/models/eventDispatcher.server.ts b/apps/webapp/app/models/eventDispatcher.server.ts new file mode 100644 index 000000000..70de2ac6a --- /dev/null +++ b/apps/webapp/app/models/eventDispatcher.server.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const JobVersionDispatchableSchema = z.object({ + type: z.literal("JOB_VERSION"), + id: z.string(), +}); + +export const DynamicTriggerDispatchableSchema = z.object({ + type: z.literal("DYNAMIC_TRIGGER"), + id: z.string(), +}); + +export const EphemeralDispatchableSchema = z.object({ + type: z.literal("EPHEMERAL"), + url: z.string(), +}); + +export const DispatchableSchema = z.discriminatedUnion("type", [ + JobVersionDispatchableSchema, + DynamicTriggerDispatchableSchema, + EphemeralDispatchableSchema, +]); diff --git a/apps/webapp/app/models/jobRunExecution.server.ts b/apps/webapp/app/models/jobRunExecution.server.ts index 0a4422e07..c22cc5846 100644 --- a/apps/webapp/app/models/jobRunExecution.server.ts +++ b/apps/webapp/app/models/jobRunExecution.server.ts @@ -1,41 +1,47 @@ -import { JobRun, JobRunExecution } from "@trigger.dev/database"; +import { JobRun } from "@trigger.dev/database"; import { PrismaClientOrTransaction } from "~/db.server"; import { executionWorker } from "~/services/worker.server"; -export type EnqueueRunExecutionV2Options = { - runAt?: Date; - resumeTaskId?: string; - isRetry?: boolean; - skipRetrying?: boolean; - executionCount?: number; -}; - -export async function enqueueRunExecutionV2( - run: JobRun, - tx: PrismaClientOrTransaction, - options: EnqueueRunExecutionV2Options = {} -) { - const job = await executionWorker.enqueue( - "performRunExecutionV2", - { - id: run.id, - reason: run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB", - resumeTaskId: options.resumeTaskId, - isRetry: typeof options.isRetry === "boolean" ? options.isRetry : false, - }, - { - tx, - runAt: options.runAt, - jobKey: `job_run:${run.id}:${options.executionCount ?? 0}${ - options.resumeTaskId ? `:task:${options.resumeTaskId}` : "" - }`, - maxAttempts: options.skipRetrying ? 1 : undefined, - } - ); -} - export async function dequeueRunExecutionV2(run: JobRun, tx: PrismaClientOrTransaction) { return await executionWorker.dequeue(`job_run:${run.id}`, { tx, }); } + +export type EnqueueRunExecutionV3Options = { + runAt?: Date; + skipRetrying?: boolean; +}; + +export async function enqueueRunExecutionV3( + run: JobRun, + tx: PrismaClientOrTransaction, + options: EnqueueRunExecutionV3Options = {} +) { + const reason = run.status === "PREPROCESSING" ? "PREPROCESS" : "EXECUTE_JOB"; + + return await executionWorker.enqueue( + "performRunExecutionV3", + { + id: run.id, + reason: reason, + }, + { + tx, + runAt: options.runAt, + queueName: `job_run:${run.id}`, + jobKey: `job_run:${reason}:${run.id}`, + maxAttempts: options.skipRetrying ? 1 : undefined, + } + ); +} + +export async function dequeueRunExecutionV3(run: JobRun, tx: PrismaClientOrTransaction) { + await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, { + tx, + }); + + await executionWorker.dequeue(`job_run:PREPROCESS:${run.id}`, { + tx, + }); +} diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 2daf0791a..07b46b1d2 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -9,60 +9,12 @@ import { customAlphabet } from "nanoid"; import slug from "slug"; import { prisma, PrismaClientOrTransaction } from "~/db.server"; import { createProject } from "./project.server"; +import { generate } from "random-words"; +import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; export type { Organization }; const nanoid = customAlphabet("1234567890abcdef", 4); -const apiKeyId = customAlphabet( - "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", - 12 -); - -export function getOrganizationFromSlug({ - userId, - slug, -}: Pick & { - userId: User["id"]; -}) { - return prisma.organization.findFirst({ - include: { - environments: true, - }, - where: { slug, members: { some: { userId } } }, - }); -} - -export function getOrganizations({ userId }: { userId: User["id"] }) { - return prisma.organization.findMany({ - where: { members: { some: { userId } } }, - orderBy: { createdAt: "desc" }, - include: { - environments: { - orderBy: { slug: "asc" }, - }, - projects: { - orderBy: { name: "asc" }, - include: { - _count: { - select: { - jobs: { - where: { - internal: false, - deletedAt: null, - }, - }, - }, - }, - }, - }, - _count: { - select: { - members: true, - }, - }, - }, - }); -} export async function createOrganization( { @@ -135,12 +87,14 @@ export async function createEnvironment( const slug = envSlug(type); const apiKey = createApiKeyForEnv(type); const pkApiKey = createPkApiKeyForEnv(type); + const shortcode = createShortcode().join("-"); return await prismaClient.runtimeEnvironment.create({ data: { slug, apiKey, pkApiKey, + shortcode, autoEnableInternalSources: type !== "DEVELOPMENT", organization: { connect: { @@ -158,27 +112,6 @@ export async function createEnvironment( }); } -function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) { - return `tr_${envSlug(envType)}_${apiKeyId(20)}`; -} - -function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) { - return `pk_${envSlug(envType)}_${apiKeyId(20)}`; -} - -function envSlug(environmentType: RuntimeEnvironment["type"]) { - switch (environmentType) { - case "DEVELOPMENT": { - return "dev"; - } - case "PRODUCTION": { - return "prod"; - } - case "STAGING": { - return "stg"; - } - case "PREVIEW": { - return "prev"; - } - } +function createShortcode() { + return generate({ exactly: 2 }); } diff --git a/apps/webapp/app/models/task.server.ts b/apps/webapp/app/models/task.server.ts index f2df0a060..f3a232185 100644 --- a/apps/webapp/app/models/task.server.ts +++ b/apps/webapp/app/models/task.server.ts @@ -1,7 +1,10 @@ import type { JobRun, Task, TaskAttempt } from "@trigger.dev/database"; import { CachedTask, ServerTask } from "@trigger.dev/core"; -export type TaskWithAttempts = Task & { attempts: TaskAttempt[]; run: JobRun }; +export type TaskWithAttempts = Task & { + attempts: TaskAttempt[]; + run: { forceYieldImmediately: boolean }; +}; export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask { return { @@ -15,7 +18,8 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask status: task.status, description: task.description, params: task.params as any, - output: task.output as any, + output: task.outputIsUndefined ? undefined : (task.output as any), + context: task.context as any, properties: task.properties as any, style: task.style as any, error: task.error, @@ -25,12 +29,13 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask operation: task.operation, callbackUrl: task.callbackUrl, forceYield: task.run.forceYieldImmediately, + childExecutionMode: task.childExecutionMode, }; } export type TaskForCaching = Pick< Task, - "id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId" + "id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId" | "outputIsUndefined" >; export function prepareTasksForCaching( @@ -103,7 +108,7 @@ function prepareTaskForCaching(task: TaskForCaching): CachedTask { status: task.status, idempotencyKey: task.idempotencyKey, noop: task.noop, - output: task.output as any, + output: task.outputIsUndefined ? undefined : (task.output as any), parentId: task.parentId, }; } diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 31e8621ed..ae81cc482 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -1,6 +1,7 @@ import type { Prisma, User } from "@trigger.dev/database"; import type { GitHubProfile } from "remix-auth-github"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; export type { User } from "@trigger.dev/database"; type FindOrCreateMagicLink = { @@ -36,6 +37,10 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise { + if (env.WHITELISTED_EMAILS && !new RegExp(env.WHITELISTED_EMAILS).test(input.email)) { + throw new Error("This email is unauthorized"); + } + const existingUser = await prisma.user.findFirst({ where: { email: input.email, diff --git a/apps/webapp/app/platform/zodWorker.server.ts b/apps/webapp/app/platform/zodWorker.server.ts index ba4e6a197..c2eb7ff65 100644 --- a/apps/webapp/app/platform/zodWorker.server.ts +++ b/apps/webapp/app/platform/zodWorker.server.ts @@ -14,9 +14,8 @@ import { run as graphileRun, parseCronItems } from "graphile-worker"; import omit from "lodash.omit"; import { z } from "zod"; import { PrismaClient, PrismaClientOrTransaction } from "~/db.server"; -import { workerLogger as logger } from "~/services/logger.server"; import { PgListenService } from "~/services/db/pgListen.server"; -import { safeJsonParse } from "~/utils/json"; +import { workerLogger as logger, trace } from "~/services/logger.server"; export interface MessageCatalogSchema { [key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; @@ -181,18 +180,13 @@ export class ZodWorker { // hijack client instance to listen and react to incoming NOTIFY events const pgListen = new PgListenService(client, this.#name, logger); - await pgListen.call("trigger:graphile:migrate", async (payload) => { - const parsedPayload = safeJsonParse(payload); + await pgListen.on("trigger:graphile:migrate", async ({ latestMigration }) => { + this.#logDebug("Detected incoming migration", { latestMigration }); - const MigrationNotificationPayloadSchema = z.object({ - latestMigration: z.number(), - }); - - const migrationPayload = MigrationNotificationPayloadSchema.parse(parsedPayload); - - this.#logDebug("Detected incoming migration", { - latestMigration: migrationPayload.latestMigration, - }); + if (latestMigration > 10) { + // already migrated past v0.14 - nothing to do + return; + } // simulate SIGTERM to trigger graceful shutdown this._handleSignal("SIGTERM"); @@ -301,7 +295,7 @@ export class ZodWorker { spec, }); - const job = await this.#addJob( + const { job, durationInMs } = await this.#addJob( identifier as string, payload, spec, @@ -313,6 +307,7 @@ export class ZodWorker { payload, spec, job, + durationInMs, }); return job; @@ -392,6 +387,8 @@ export class ZodWorker { spec: TaskSpec, tx: PrismaClientOrTransaction ) { + const now = performance.now(); + const results = await tx.$queryRawUnsafe( `SELECT * FROM ${this.graphileWorkerSchema}.add_job( identifier => $1::text, @@ -415,6 +412,8 @@ export class ZodWorker { spec.jobKeyMode || null ); + const durationInMs = performance.now() - now; + const rows = AddJobResultsSchema.safeParse(results); if (!rows.success) { @@ -425,7 +424,7 @@ export class ZodWorker { const job = rows.data[0]; - return job as GraphileJob; + return { job: job as GraphileJob, durationInMs: Math.floor(durationInMs) }; } async #addBatchJob( @@ -603,7 +602,15 @@ export class ZodWorker { throw new Error(`No task for message type: ${String(typeName)}`); } - await task.handler(payload, job); + await trace( + { + worker_job: job, + worker_name: this.#name, + }, + async () => { + await task.handler(payload, job); + } + ); } async #handleRecurringTask( diff --git a/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts b/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts index bd5f4ef87..23f803926 100644 --- a/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts +++ b/apps/webapp/app/presenters/EnvironmentsPresenter.server.ts @@ -14,6 +14,7 @@ import { IndexEndpointStats, parseEndpointIndexStats, } from "@trigger.dev/core"; +import { sortEnvironments } from "~/services/environmentSort.server"; export type Client = { slug: string; @@ -197,31 +198,20 @@ export class EnvironmentsPresenter { } return { - environments: filtered - .map((environment) => ({ + environments: sortEnvironments( + filtered.map((environment) => ({ id: environment.id, apiKey: environment.apiKey, pkApiKey: environment.pkApiKey, type: environment.type, slug: environment.slug, })) - .sort((a, b) => { - const aIndex = environmentSortOrder.indexOf(a.type); - const bIndex = environmentSortOrder.indexOf(b.type); - return aIndex - bIndex; - }), + ), clients, }; } } -const environmentSortOrder: RuntimeEnvironmentType[] = [ - "DEVELOPMENT", - "PREVIEW", - "STAGING", - "PRODUCTION", -]; - function endpointClient( endpoint: Pick & { indexings: Pick[]; diff --git a/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts b/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts index b1f4c659c..3d7bf8877 100644 --- a/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/EnvironmentsStreamPresenter.server.ts @@ -1,7 +1,7 @@ import { PrismaClient, prisma } from "~/db.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; -import { sse } from "~/utils/sse"; +import { sse } from "~/utils/sse.server"; type EnvironmentSignalsMap = { [x: string]: { diff --git a/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts new file mode 100644 index 000000000..886b4192d --- /dev/null +++ b/apps/webapp/app/presenters/HttpEndpointPresenter.server.ts @@ -0,0 +1,164 @@ +import { z } from "zod"; +import { PrismaClient, prisma } from "~/db.server"; +import { sortEnvironments } from "~/services/environmentSort.server"; +import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { projectPath } from "~/utils/pathBuilder"; + +export class HttpEndpointPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + httpEndpointKey, + }: { + userId: string; + projectSlug: string; + organizationSlug: string; + httpEndpointKey: string; + }) { + const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({ + select: { + id: true, + key: true, + icon: true, + title: true, + updatedAt: true, + projectId: true, + secretReference: { + select: { + key: true, + provider: true, + }, + }, + httpEndpointEnvironments: { + select: { + id: true, + immediateResponseFilter: true, + skipTriggeringRuns: true, + source: true, + active: true, + updatedAt: true, + environment: { + select: { + type: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + }, + webhook: { + select: { + id: true, + key: true, + }, + }, + }, + where: { + key: httpEndpointKey, + project: { + slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + if (!httpEndpoint) { + throw new Error("Could not find http endpoint"); + } + + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + type: true, + slug: true, + shortcode: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + where: { + projectId: httpEndpoint.projectId, + }, + }); + + const relevantEnvironments = sortEnvironments( + environments + .filter( + (environment) => environment.orgMember === null || environment.orgMember.userId === userId + ) + .map((environment) => ({ + ...environment, + webhookUrl: httpEndpointUrl({ httpEndpointId: httpEndpoint.id, environment }), + })) + ); + + //get the secret + const secretStore = getSecretStore(httpEndpoint.secretReference.provider); + let secret: string | undefined; + try { + const secretData = await secretStore.getSecretOrThrow( + z.object({ secret: z.string() }), + httpEndpoint.secretReference.key + ); + secret = secretData.secret; + } catch (e) { + let error = e instanceof Error ? e.message : JSON.stringify(e); + throw new Error(`Could not retrieve secret: ${error}`); + } + if (!secret) { + throw new Error("Could not find secret"); + } + + const httpEndpointEnvironments = httpEndpoint.httpEndpointEnvironments + .filter( + (httpEndpointEnvironment) => + httpEndpointEnvironment.environment.orgMember === null || + httpEndpointEnvironment.environment.orgMember.userId === userId + ) + .map((endpointEnv) => ({ + ...endpointEnv, + immediateResponseFilter: endpointEnv.immediateResponseFilter != null, + environment: { + type: endpointEnv.environment.type, + }, + webhookUrl: relevantEnvironments.find((e) => e.type === endpointEnv.environment.type) + ?.webhookUrl, + })); + + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + return { + httpEndpoint: { + ...httpEndpoint, + httpEndpointEnvironments, + webhookLink: httpEndpoint.webhook + ? `${projectRootPath}/triggers/webhooks/${httpEndpoint.webhook.id}` + : undefined, + }, + environments: relevantEnvironments, + unconfiguredEnvironments: relevantEnvironments.filter( + (e) => httpEndpointEnvironments.find((h) => h.environment.type === e.type) === undefined + ), + secret, + }; + } +} diff --git a/apps/webapp/app/presenters/HttpEndpointsPresenter.server.ts b/apps/webapp/app/presenters/HttpEndpointsPresenter.server.ts new file mode 100644 index 000000000..8f4d3134f --- /dev/null +++ b/apps/webapp/app/presenters/HttpEndpointsPresenter.server.ts @@ -0,0 +1,64 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; + +export class HttpEndpointsPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + slug, + }: Pick & { + userId: User["id"]; + }) { + const httpEndpoints = await this.#prismaClient.triggerHttpEndpoint.findMany({ + select: { + id: true, + key: true, + icon: true, + title: true, + updatedAt: true, + httpEndpointEnvironments: { + select: { + id: true, + environment: { + select: { + type: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + }, + }, + where: { + project: { + slug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + return httpEndpoints.map((httpEndpoint) => ({ + ...httpEndpoint, + httpEndpointEnvironments: httpEndpoint.httpEndpointEnvironments.filter( + (httpEndpointEnvironment) => + httpEndpointEnvironment.environment.orgMember === null || + httpEndpointEnvironment.environment.orgMember.userId === userId + ), + })); + } +} diff --git a/apps/webapp/app/presenters/IntegrationClientConnectionsPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientConnectionsPresenter.server.ts index f1438ea3f..3e02167ec 100644 --- a/apps/webapp/app/presenters/IntegrationClientConnectionsPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationClientConnectionsPresenter.server.ts @@ -1,7 +1,6 @@ import { User } from "@trigger.dev/database"; import { PrismaClient, prisma } from "~/db.server"; import { Organization } from "~/models/organization.server"; -import { Project } from "~/models/project.server"; import { ConnectionMetadataSchema } from "~/services/externalApis/types"; export class IntegrationClientConnectionsPresenter { @@ -14,12 +13,10 @@ export class IntegrationClientConnectionsPresenter { public async call({ userId, organizationSlug, - projectSlug, clientSlug, }: { userId: User["id"]; organizationSlug: Organization["slug"]; - projectSlug: Project["slug"]; clientSlug: string; }) { const connections = await this.#prismaClient.integrationConnection.findMany({ diff --git a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts index 901d79b17..ae88353fe 100644 --- a/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationClientPresenter.server.ts @@ -2,9 +2,7 @@ import { User } from "@trigger.dev/database"; import { PrismaClient, prisma } from "~/db.server"; import { env } from "~/env.server"; import { Organization } from "~/models/organization.server"; -import { Project } from "~/models/project.server"; -import { integrationCatalog } from "~/services/externalApis/integrationCatalog.server"; -import { Help, HelpSchema, OAuthClientSchema } from "~/services/externalApis/types"; +import { HelpSchema, OAuthClientSchema } from "~/services/externalApis/types"; import { getSecretStore } from "~/services/secrets/secretStore.server"; export class IntegrationClientPresenter { @@ -17,12 +15,10 @@ export class IntegrationClientPresenter { public async call({ userId, organizationSlug, - projectSlug, clientSlug, }: { userId: User["id"]; organizationSlug: Organization["slug"]; - projectSlug: Project["slug"]; clientSlug: string; }) { const integration = await this.#prismaClient.integration.findFirst({ @@ -59,8 +55,8 @@ export class IntegrationClientPresenter { jobIntegrations: { where: { job: { - project: { - slug: projectSlug, + organization: { + slug: organizationSlug, }, internal: false, deletedAt: null, @@ -121,11 +117,11 @@ export class IntegrationClientPresenter { }, authMethod: { type: - integration.authMethod?.type ?? integration.authSource === "RESOLVER" ? "local" : "local", + integration.authMethod?.type ?? + (integration.authSource === "RESOLVER" ? "resolver" : "local"), name: - integration.authMethod?.name ?? integration.authSource === "RESOLVER" - ? "Auth Resolver" - : "Local Auth", + integration.authMethod?.name ?? + (integration.authSource === "RESOLVER" ? "Auth Resolver" : "Local Auth"), }, help, }; diff --git a/apps/webapp/app/presenters/IntegrationClientScopesPresenter.server.ts b/apps/webapp/app/presenters/IntegrationClientScopesPresenter.server.ts index 4687ee0f9..25bbc783a 100644 --- a/apps/webapp/app/presenters/IntegrationClientScopesPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationClientScopesPresenter.server.ts @@ -15,12 +15,10 @@ export class IntegrationClientScopesPresenter { public async call({ userId, organizationSlug, - projectSlug, clientSlug, }: { userId: User["id"]; organizationSlug: Organization["slug"]; - projectSlug: Project["slug"]; clientSlug: string; }) { const integration = await this.#prismaClient.integration.findFirst({ diff --git a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts index c38449a17..5b3c003fb 100644 --- a/apps/webapp/app/presenters/IntegrationsPresenter.server.ts +++ b/apps/webapp/app/presenters/IntegrationsPresenter.server.ts @@ -3,7 +3,7 @@ import { PrismaClient, prisma } from "~/db.server"; import { env } from "~/env.server"; import { Organization } from "~/models/organization.server"; import { Project } from "~/models/project.server"; -import { Api, apisList } from "~/services/externalApis/apis"; +import { Api, apisList } from "~/services/externalApis/apis.server"; import { integrationCatalog } from "~/services/externalApis/integrationCatalog.server"; import { Integration, OAuthClientSchema } from "~/services/externalApis/types"; import { getSecretStore } from "~/services/secrets/secretStore.server"; @@ -25,11 +25,9 @@ export class IntegrationsPresenter { public async call({ userId, - projectSlug, organizationSlug, }: { userId: User["id"]; - projectSlug: Project["slug"]; organizationSlug: Organization["slug"]; }) { const clients = await this.#prismaClient.integration.findMany({ @@ -67,8 +65,8 @@ export class IntegrationsPresenter { jobIntegrations: { where: { job: { - project: { - slug: projectSlug, + organization: { + slug: organizationSlug, }, internal: false, deletedAt: null, @@ -125,9 +123,9 @@ export class IntegrationsPresenter { name: c.definition.name, }, authMethod: { - type: c.authMethod?.type ?? c.authSource === "RESOLVER" ? "resolver" : "local", + type: c.authMethod?.type ?? (c.authSource === "RESOLVER" ? "resolver" : "local"), name: - c.authMethod?.name ?? c.authSource === "RESOLVER" ? "Auth Resolver" : "Local Only", + c.authMethod?.name ?? (c.authSource === "RESOLVER" ? "Auth Resolver" : "Local Only"), }, authSource: c.authSource, setupStatus: c.setupStatus, diff --git a/apps/webapp/app/presenters/JobListPresenter.server.ts b/apps/webapp/app/presenters/JobListPresenter.server.ts index 3c997c646..3f0e54df6 100644 --- a/apps/webapp/app/presenters/JobListPresenter.server.ts +++ b/apps/webapp/app/presenters/JobListPresenter.server.ts @@ -8,6 +8,9 @@ import { Organization } from "~/models/organization.server"; import { Project } from "~/models/project.server"; import { User } from "~/models/user.server"; import { z } from "zod"; +import { projectPath } from "~/utils/pathBuilder"; + +export type ProjectJob = Awaited>[0]; export class JobListPresenter { #prismaClient: PrismaClient; @@ -23,8 +26,8 @@ export class JobListPresenter { integrationSlug, }: { userId: User["id"]; - projectSlug: Project["slug"]; - organizationSlug?: Organization["slug"]; + projectSlug?: Project["slug"]; + organizationSlug: Organization["slug"]; integrationSlug?: string; }) { const orgWhere: Prisma.JobWhereInput["organization"] = organizationSlug @@ -68,6 +71,8 @@ export class JobListPresenter { }, }, }, + triggerLink: true, + triggerHelp: true, }, }, environment: { @@ -90,14 +95,21 @@ export class JobListPresenter { type: true, }, }, + project: { + select: { + slug: true, + }, + }, }, where: { internal: false, deletedAt: null, organization: orgWhere, - project: { - slug: projectSlug, - }, + project: projectSlug + ? { + slug: projectSlug, + } + : undefined, integrations: integrationsWhere, }, orderBy: [{ title: "asc" }], @@ -182,6 +194,11 @@ export class JobListPresenter { title: eventSpecification.title, icon: eventSpecification.icon, source: eventSpecification.source, + link: projectSlug + ? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${ + alias.version.triggerLink + }` + : undefined, }, integrations, hasIntegrationsRequiringAction: integrations.some( @@ -190,6 +207,7 @@ export class JobListPresenter { lastRun, properties, environments, + projectSlug: job.project.slug, }; }) .filter(Boolean); diff --git a/apps/webapp/app/presenters/JobPresenter.server.ts b/apps/webapp/app/presenters/JobPresenter.server.ts new file mode 100644 index 000000000..957ed7200 --- /dev/null +++ b/apps/webapp/app/presenters/JobPresenter.server.ts @@ -0,0 +1,236 @@ +import { + DisplayProperty, + DisplayPropertySchema, + EventSpecificationSchema, + TriggerHelpSchema, +} from "@trigger.dev/core"; +import { PrismaClient, Prisma, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; +import { z } from "zod"; +import { projectPath } from "~/utils/pathBuilder"; +import { Job } from "@trigger.dev/database"; + +export class JobPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + jobSlug, + projectSlug, + organizationSlug, + }: { + userId: User["id"]; + jobSlug: Job["slug"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + }) { + const job = await this.#prismaClient.job.findFirst({ + select: { + id: true, + slug: true, + title: true, + aliases: { + select: { + version: { + select: { + version: true, + eventSpecification: true, + properties: true, + status: true, + runs: { + select: { + createdAt: true, + status: true, + }, + take: 1, + orderBy: [{ createdAt: "desc" }], + }, + integrations: { + select: { + key: true, + integration: { + select: { + slug: true, + definition: true, + setupStatus: true, + }, + }, + }, + }, + triggerLink: true, + triggerHelp: true, + }, + }, + environment: { + select: { + type: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + where: { + name: "latest", + }, + }, + dynamicTriggers: { + select: { + type: true, + }, + }, + project: { + select: { + slug: true, + }, + }, + _count: { + select: { + runs: { + where: { + isTest: false, + }, + }, + }, + }, + }, + where: { + slug: jobSlug, + deletedAt: null, + organization: { + members: { + some: { + userId, + }, + }, + }, + project: { + slug: projectSlug, + }, + }, + }); + + if (!job) { + return undefined; + } + + //the best alias to select: + // 1. Logged-in user dev + // 2. Prod + // 3. Any other user's dev + const sortedAliases = job.aliases.sort((a, b) => { + if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) { + return -1; + } + + if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) { + return 1; + } + + if (a.environment.type === "PRODUCTION") { + return -1; + } + + if (b.environment.type === "PRODUCTION") { + return 1; + } + + return 0; + }); + + const alias = sortedAliases.at(0); + + if (!alias) { + throw new Error(`No aliases found for job ${job.id}, this should never happen.`); + } + + const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification); + + const lastRuns = job.aliases + .map((alias) => alias.version.runs.at(0)) + .filter(Boolean) + .sort((a, b) => { + return b.createdAt.getTime() - a.createdAt.getTime(); + }); + + const lastRun = lastRuns.at(0); + + const integrations = alias.version.integrations.map((integration) => ({ + key: integration.key, + title: integration.integration.slug, + icon: integration.integration.definition.icon ?? integration.integration.definition.id, + setupStatus: integration.integration.setupStatus, + })); + + let properties: DisplayProperty[] = []; + + if (eventSpecification.properties) { + properties = [...properties, ...eventSpecification.properties]; + } + + if (alias.version.properties) { + const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties); + properties = [...properties, ...versionProperties]; + } + + const environments = job.aliases.map((alias) => ({ + type: alias.environment.type, + enabled: alias.version.status === "ACTIVE", + lastRun: alias.version.runs.at(0)?.createdAt, + version: alias.version.version, + })); + + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + //we exclude test runs from this count + const hasRealRuns = job._count.runs > 0; + + return { + id: job.id, + slug: job.slug, + title: job.title, + version: alias.version.version, + status: alias.version.status, + dynamic: job.dynamicTriggers.length > 0, + event: { + title: eventSpecification.title, + icon: eventSpecification.icon, + source: eventSpecification.source, + link: alias.version.triggerLink + ? `${projectRootPath}/${alias.version.triggerLink}` + : undefined, + }, + noRunsHelp: hasRealRuns + ? undefined + : this.#getNoRunsHelp(alias.version.triggerHelp, projectRootPath), + integrations, + hasIntegrationsRequiringAction: integrations.some((i) => i.setupStatus === "MISSING_FIELDS"), + lastRun, + properties, + environments, + }; + } + + #getNoRunsHelp(data: Prisma.JsonValue, projectPath: string) { + const triggerHelp = TriggerHelpSchema.nullish().parse(data); + if (!triggerHelp) { + return undefined; + } + + if (triggerHelp.noRuns) { + triggerHelp.noRuns.link = triggerHelp.noRuns.link + ? `${projectPath}/${triggerHelp.noRuns.link}` + : undefined; + + return triggerHelp.noRuns; + } + } +} diff --git a/apps/webapp/app/presenters/NewOrganizationPresenter.server.ts b/apps/webapp/app/presenters/NewOrganizationPresenter.server.ts new file mode 100644 index 000000000..4d3fe0aae --- /dev/null +++ b/apps/webapp/app/presenters/NewOrganizationPresenter.server.ts @@ -0,0 +1,20 @@ +import { PrismaClient, User } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; + +export class NewOrganizationPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId }: { userId: User["id"] }) { + const organizations = await this.#prismaClient.organization.findMany({ + where: { members: { some: { userId } } }, + }); + + return { + hasOrganizations: organizations.length > 0, + }; + } +} diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts new file mode 100644 index 000000000..6e3641a1f --- /dev/null +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -0,0 +1,130 @@ +import { PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { getCurrentProjectId } from "~/services/currentProject.server"; +import { ProjectPresenter } from "./ProjectPresenter.server"; +import { logger } from "~/services/logger.server"; + +type Org = Awaited>[number]; + +export class OrganizationsPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + organizationSlug, + request, + projectSlug, + }: { + userId: string; + organizationSlug: string; + request: Request; + projectSlug?: string; + }) { + const organizations = await this.getOrganizations(userId); + + const organization = organizations.find((o) => o.slug === organizationSlug); + if (!organization) { + logger.info("Not Found: organization", { + organizationSlug, + projectSlug, + request, + organization, + }); + throw new Response("Not Found", { status: 404 }); + } + + const project = await this.getProject(organization, projectSlug, request, userId); + + return { organizations, organization, project }; + } + + async getOrganizations(userId: string) { + const orgs = await this.#prismaClient.organization.findMany({ + where: { members: { some: { userId } } }, + orderBy: { createdAt: "desc" }, + include: { + projects: { + orderBy: { name: "asc" }, + include: { + _count: { + select: { + jobs: { + where: { + internal: false, + deletedAt: null, + }, + }, + sources: { + where: { + active: false, + }, + }, + httpEndpoints: true, + }, + }, + }, + }, + _count: { + select: { + members: true, + integrations: { + where: { + setupStatus: "MISSING_FIELDS", + }, + }, + }, + }, + }, + }); + + return orgs.map((org) => { + return { + id: org.id, + slug: org.slug, + title: org.title, + projects: org.projects.map((project) => ({ + id: project.id, + slug: project.slug, + name: project.name, + jobCount: project._count.jobs, + })), + hasUnconfiguredIntegrations: org._count.integrations > 0, + memberCount: org._count.members, + }; + }); + } + + async getProject( + organization: Org, + projectSlug: string | undefined, + request: Request, + userId: string + ) { + const projectPresenter = new ProjectPresenter(); + + if (!projectSlug) { + const projectId = await getCurrentProjectId(request); + const orgProject = organization.projects.find((p) => p.id === projectId); + if (!orgProject) { + logger.info("Not Found: proj 1", { + projectId, + organization, + projectSlug: projectSlug ?? null, + }); + throw new Response("Not Found", { status: 404 }); + } + projectSlug = orgProject.slug; + } + + const project = await projectPresenter.call({ userId, slug: projectSlug }); + if (!project) { + logger.info("Not Found: proj 2", { projectSlug, organization, project }); + throw new Response("Not Found", { status: 404 }); + } + return project; + } +} diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts index fe55f354a..0d18091c1 100644 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ b/apps/webapp/app/presenters/ProjectPresenter.server.ts @@ -91,6 +91,13 @@ export class ProjectPresenter { active: false, }, }, + jobs: { + where: { + internal: false, + deletedAt: null, + }, + }, + httpEndpoints: true, }, }, organization: { @@ -135,7 +142,8 @@ export class ProjectPresenter { createdAt: project.createdAt, updatedAt: project.updatedAt, hasInactiveExternalTriggers: project._count.sources > 0, - hasUnconfiguredIntegrations: project.organization._count.integrations > 0, + jobCount: project._count.jobs, + httpEndpointCount: project._count.httpEndpoints, environments: project.environments.map((environment) => ({ id: environment.id, slug: environment.slug, diff --git a/apps/webapp/app/presenters/RunPresenter.server.ts b/apps/webapp/app/presenters/RunPresenter.server.ts index 34934647d..8b14e0e49 100644 --- a/apps/webapp/app/presenters/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/RunPresenter.server.ts @@ -156,6 +156,7 @@ export class RunPresenter { completedAt: true, style: true, parentId: true, + noop: true, runConnection: { select: { integration: { diff --git a/apps/webapp/app/presenters/RunStreamPresenter.server.ts b/apps/webapp/app/presenters/RunStreamPresenter.server.ts index d53ec3518..f7c943dcc 100644 --- a/apps/webapp/app/presenters/RunStreamPresenter.server.ts +++ b/apps/webapp/app/presenters/RunStreamPresenter.server.ts @@ -1,6 +1,6 @@ import { JobRun } from "@trigger.dev/database"; import { PrismaClient, prisma } from "~/db.server"; -import { sse } from "~/utils/sse"; +import { sse } from "~/utils/sse.server"; export class RunStreamPresenter { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/presenters/SelectBestProjectPresenter.server.ts b/apps/webapp/app/presenters/SelectBestProjectPresenter.server.ts new file mode 100644 index 000000000..55adbd6cc --- /dev/null +++ b/apps/webapp/app/presenters/SelectBestProjectPresenter.server.ts @@ -0,0 +1,49 @@ +import { PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { getCurrentProjectId } from "~/services/currentProject.server"; + +export class SelectBestProjectPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId, request }: { userId: string; request: Request }) { + //try get current project from cookie + const projectId = await getCurrentProjectId(request); + if (projectId) { + const project = await this.#prismaClient.project.findUnique({ + where: { id: projectId, organization: { members: { some: { userId } } } }, + include: { organization: true }, + }); + if (project) { + return { project, organization: project.organization }; + } + } + + //failing that, we pick the project with the most jobs + const projects = await this.#prismaClient.project.findMany({ + include: { + organization: true, + }, + where: { + organization: { + members: { some: { userId } }, + }, + }, + orderBy: { + jobs: { + _count: "desc", + }, + }, + take: 1, + }); + + if (projects.length === 0) { + throw new Response("Not Found", { status: 404 }); + } + + return { project: projects[0], organization: projects[0].organization }; + } +} diff --git a/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts index 4d27df4b7..afcf56da9 100644 --- a/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts +++ b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts @@ -1,5 +1,4 @@ -import { RedactSchema } from "@trigger.dev/core"; -import { StyleSchema } from "@trigger.dev/core"; +import { RedactSchema, StyleSchema } from "@trigger.dev/core"; import { PrismaClient, prisma } from "~/db.server"; import { mergeProperties } from "~/utils/mergeProperties.server"; import { Redactor } from "~/utils/redactor"; @@ -58,6 +57,7 @@ export class TaskDetailsPresenter { outputProperties: true, params: true, output: true, + outputIsUndefined: true, error: true, startedAt: true, completedAt: true, @@ -89,9 +89,11 @@ export class TaskDetailsPresenter { return { ...task, redact: undefined, - output: task.output - ? JSON.stringify(this.#stringifyOutputWithRedactions(task.output, task.redact), null, 2) - : undefined, + output: JSON.stringify( + this.#stringifyOutputWithRedactions(task.output, task.redact), + null, + 2 + ), connection: task.runConnection, params: task.params as Record, properties: mergeProperties(task.properties, task.outputProperties), @@ -101,7 +103,7 @@ export class TaskDetailsPresenter { #stringifyOutputWithRedactions(output: any, redact: unknown): any { if (!output) { - return; + return output; } const parsedRedact = RedactSchema.safeParse(redact); diff --git a/apps/webapp/app/presenters/TestJobPresenter.server.ts b/apps/webapp/app/presenters/TestJobPresenter.server.ts index c87b035c8..cb6665994 100644 --- a/apps/webapp/app/presenters/TestJobPresenter.server.ts +++ b/apps/webapp/app/presenters/TestJobPresenter.server.ts @@ -66,6 +66,18 @@ export class TestJobPresenter { }, where: { name: "latest", + environment: { + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, }, }, runs: { diff --git a/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts new file mode 100644 index 000000000..3aec19238 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookDeliveryListPresenter.server.ts @@ -0,0 +1,117 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Direction } from "./RunListPresenter.server"; + +type RunListOptions = { + userId: string; + webhookId: string; + direction?: Direction; + cursor?: string; +}; + +const PAGE_SIZE = 20; + +export type WebhookDeliveryList = Awaited>; + +export class WebhookDeliveryListPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId, webhookId, direction = "forward", cursor }: RunListOptions) { + const directionMultiplier = direction === "forward" ? 1 : -1; + + const runs = await this.#prismaClient.webhookRequestDelivery.findMany({ + select: { + id: true, + number: true, + createdAt: true, + deliveredAt: true, + verified: true, + error: true, + environment: { + select: { + type: true, + slug: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + }, + }, + where: { + webhookId, + environment: { + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, + }, + orderBy: [{ id: "desc" }], + //take an extra page to tell if there are more + take: directionMultiplier * (PAGE_SIZE + 1), + //skip the cursor if there is one + skip: cursor ? 1 : 0, + cursor: cursor + ? { + id: cursor, + } + : undefined, + }); + + const hasMore = runs.length > PAGE_SIZE; + + //get cursors for next and previous pages + let next: string | undefined; + let previous: string | undefined; + switch (direction) { + case "forward": + previous = cursor ? runs.at(0)?.id : undefined; + if (hasMore) { + next = runs[PAGE_SIZE - 1]?.id; + } + break; + case "backward": + if (hasMore) { + previous = runs[1]?.id; + next = runs[PAGE_SIZE]?.id; + } else { + next = runs[PAGE_SIZE - 1]?.id; + } + break; + } + + const runsToReturn = + direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE); + + return { + runs: runsToReturn.map((run) => ({ + id: run.id, + number: run.number, + createdAt: run.createdAt, + deliveredAt: run.deliveredAt, + verified: run.verified, + error: run.error, + environment: { + type: run.environment.type, + slug: run.environment.slug, + userId: run.environment.orgMember?.userId, + }, + })), + pagination: { + next, + previous, + }, + }; + } +} diff --git a/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts new file mode 100644 index 000000000..5acd8a639 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookDeliveryPresenter.server.ts @@ -0,0 +1,96 @@ +import { User, Webhook } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { Direction } from "./RunListPresenter.server"; +import { organizationPath, projectPath } from "~/utils/pathBuilder"; +import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server"; + +export class WebhookDeliveryPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + webhookId, + direction = "forward", + cursor, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + webhookId: Webhook["id"]; + direction?: Direction; + cursor?: string; + }) { + const webhook = await this.#prismaClient.webhook.findUnique({ + select: { + id: true, + key: true, + active: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + httpEndpoint: { + select: { + key: true, + }, + }, + createdAt: true, + updatedAt: true, + params: true, + }, + where: { + id: webhookId, + }, + }); + + if (!webhook) { + throw new Error("Webhook source not found"); + } + + const deliveryListPresenter = new WebhookDeliveryListPresenter(this.#prismaClient); + + const orgRootPath = organizationPath({ slug: organizationSlug }); + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + const requestDeliveries = await deliveryListPresenter.call({ + userId, + webhookId: webhook.id, + direction, + cursor, + }); + + return { + webhook: { + id: webhook.id, + key: webhook.key, + active: webhook.active, + integration: webhook.integration, + integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`, + httpEndpoint: webhook.httpEndpoint, + httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + params: webhook.params, + requestDeliveries, + }, + }; + } +} diff --git a/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts new file mode 100644 index 000000000..bc8244905 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookSourcePresenter.server.ts @@ -0,0 +1,106 @@ +import { User, Webhook } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Organization } from "~/models/organization.server"; +import { Project } from "~/models/project.server"; +import { Direction, RunListPresenter } from "./RunListPresenter.server"; +import { organizationPath, projectPath } from "~/utils/pathBuilder"; + +export class WebhookSourcePresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + webhookId, + direction = "forward", + cursor, + getDeliveryRuns = false, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + webhookId: Webhook["id"]; + direction?: Direction; + cursor?: string; + getDeliveryRuns?: boolean; + }) { + const webhook = await this.#prismaClient.webhook.findUnique({ + select: { + id: true, + key: true, + active: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + httpEndpoint: { + select: { + key: true, + }, + }, + createdAt: true, + updatedAt: true, + params: true, + }, + where: { + id: webhookId, + }, + }); + + if (!webhook) { + throw new Error("Webhook source not found"); + } + + const runListPresenter = new RunListPresenter(this.#prismaClient); + const jobSlug = getDeliveryRuns + ? getDeliveryJobSlug(webhook.key) + : getRegistrationJobSlug(webhook.key); + + const runList = await runListPresenter.call({ + userId, + jobSlug, + organizationSlug, + projectSlug, + direction, + cursor, + }); + + const orgRootPath = organizationPath({ slug: organizationSlug }); + const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug }); + + return { + trigger: { + id: webhook.id, + key: webhook.key, + active: webhook.active, + integration: webhook.integration, + integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`, + httpEndpoint: webhook.httpEndpoint, + httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + params: webhook.params, + runList, + }, + }; + } +} + +const getRegistrationJobSlug = (key: string) => `webhook.register.${key}`; + +const getDeliveryJobSlug = (key: string) => `webhook.deliver.${key}`; diff --git a/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts new file mode 100644 index 000000000..a9e76cae5 --- /dev/null +++ b/apps/webapp/app/presenters/WebhookTriggersPresenter.server.ts @@ -0,0 +1,71 @@ +import { Organization, User } from "@trigger.dev/database"; +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; + +export class WebhookTriggersPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + organizationSlug, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + organizationSlug: Organization["slug"]; + }) { + const webhooks = await this.#prismaClient.webhook.findMany({ + select: { + id: true, + key: true, + active: true, + params: true, + integration: { + select: { + id: true, + title: true, + slug: true, + definitionId: true, + setupStatus: true, + definition: { + select: { + icon: true, + }, + }, + }, + }, + webhookEnvironments: { + select: { + id: true, + environment: { + select: { + type: true + } + } + } + }, + createdAt: true, + updatedAt: true, + }, + where: { + project: { + slug: projectSlug, + organization: { + slug: organizationSlug, + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + return { webhooks }; + } +} diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 9819685cb..91eb125d0 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -1,7 +1,10 @@ -import type { LinksFunction, LoaderArgs } from "@remix-run/node"; +import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react"; +import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; import type { ShouldRevalidateFunction } from "@remix-run/react"; import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react"; +import { metaV1 } from "@remix-run/v1-meta"; import { TypedMetaFunction, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { ExternalScripts } from "remix-utils/external-scripts"; import type { ToastMessage } from "~/models/message.server"; import { commitSession, getSession } from "~/models/message.server"; import tailwindStylesheetUrl from "~/tailwind.css"; @@ -11,24 +14,24 @@ import { AppContainer, MainCenteredContainer } from "./components/layout/AppLayo import { Toast } from "./components/primitives/Toast"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; +import { useHighlight } from "./hooks/useHighlight"; import { usePostHog } from "./hooks/usePostHog"; import { getUser } from "./services/session.server"; import { appEnvTitleTag } from "./utils"; -import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react"; -import { useHighlight } from "./hooks/useHighlight"; -import { ExternalScripts } from "remix-utils"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; }; -export const meta: TypedMetaFunction = ({ data }) => ({ - title: `Trigger.dev${appEnvTitleTag(data?.appEnv)}`, - charset: "utf-8", - viewport: "width=1024, initial-scale=1", -}); +export const meta: TypedMetaFunction = (args) => { + return metaV1(args, { + title: `Trigger.dev${appEnvTitleTag(args.data.appEnv)}`, + charset: "utf-8", + viewport: "width=1024, initial-scale=1", + }); +}; -export const loader = async ({ request }: LoaderArgs) => { +export const loader = async ({ request }: LoaderFunctionArgs) => { const session = await getSession(request.headers.get("cookie")); const toastMessage = session.get("toastMessage") as ToastMessage; const posthogProjectKey = env.POSTHOG_PROJECT_KEY; diff --git a/apps/webapp/app/routes/_app._index/route.tsx b/apps/webapp/app/routes/_app._index/route.tsx new file mode 100644 index 000000000..a3889f840 --- /dev/null +++ b/apps/webapp/app/routes/_app._index/route.tsx @@ -0,0 +1,26 @@ +import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime"; +import { getUsersInvites } from "~/models/member.server"; +import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server"; +import { requireUser } from "~/services/session.server"; +import { invitesPath, newOrganizationPath, projectPath } from "~/utils/pathBuilder"; + +//this loader chooses the best project to redirect you to, ideally based on the cookie +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = await requireUser(request); + + //if there are invites then we should redirect to the invites page + const invites = await getUsersInvites({ email: user.email }); + if (invites.length > 0) { + return redirect(invitesPath()); + } + + const presenter = new SelectBestProjectPresenter(); + try { + const { project, organization } = await presenter.call({ userId: user.id, request }); + //redirect them to the most appropriate project + return redirect(projectPath(organization, project)); + } catch (e) { + //this should only happen if the user has no projects, and no invites + return redirect(newOrganizationPath()); + } +}; diff --git a/apps/webapp/app/routes/_app._orgaccount._index/OrganizationGrid.tsx b/apps/webapp/app/routes/_app._orgaccount._index/OrganizationGrid.tsx deleted file mode 100644 index 5a7b004ac..000000000 --- a/apps/webapp/app/routes/_app._orgaccount._index/OrganizationGrid.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { ChevronRightIcon } from "@heroicons/react/24/solid"; -import { Link } from "@remix-run/react"; -import simplur from "simplur"; -import { Badge } from "~/components/primitives/Badge"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { Header2 } from "~/components/primitives/Headers"; -import { NamedIcon } from "~/components/primitives/NamedIcon"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import type { MatchedOrganization } from "~/hooks/useOrganizations"; -import { cn } from "~/utils/cn"; -import { newProjectPath, organizationPath, projectPath } from "~/utils/pathBuilder"; - -export function OrganizationGridItem({ organization }: { organization: MatchedOrganization }) { - return ( -
  • -
    - -
    -
    -
    -
  • - ); -} diff --git a/apps/webapp/app/routes/_app._orgaccount._index/route.tsx b/apps/webapp/app/routes/_app._orgaccount._index/route.tsx deleted file mode 100644 index 69a9269f9..000000000 --- a/apps/webapp/app/routes/_app._orgaccount._index/route.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { LoaderArgs, json } from "@remix-run/server-runtime"; -import { redirect } from "remix-typedjson"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { useOptionalOrganizations } from "~/hooks/useOrganizations"; -import { getUsersInvites } from "~/models/member.server"; -import { getOrganizations } from "~/models/organization.server"; -import { requireUser } from "~/services/session.server"; -import { invitesPath, newOrganizationPath } from "~/utils/pathBuilder"; -import { OrganizationGridItem } from "./OrganizationGrid"; -import { Link } from "@remix-run/react"; -import { NamedIcon } from "~/components/primitives/NamedIcon"; -import { Paragraph } from "~/components/primitives/Paragraph"; - -export const loader = async ({ request }: LoaderArgs) => { - const user = await requireUser(request); - - //if there are invites then we should redirect to the invites page - const invites = await getUsersInvites({ email: user.email }); - if (invites.length > 0) { - return redirect(invitesPath()); - } - - //if there are no orgs, then redirect to create an org - const organizations = await getOrganizations({ userId: user.id }); - if (organizations.length === 0) { - return redirect(newOrganizationPath()); - } - - return json({}); -}; - -export default function Page() { - const organizations = useOptionalOrganizations(); - - return ( -
      - <> - {organizations && - organizations.map((organization) => ( - - ))} - -
    • - -
      -
      - -
    • -
    - ); -} diff --git a/apps/webapp/app/routes/_app._orgaccount/route.tsx b/apps/webapp/app/routes/_app._orgaccount/route.tsx deleted file mode 100644 index f23f336f5..000000000 --- a/apps/webapp/app/routes/_app._orgaccount/route.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { ArrowRightOnRectangleIcon } from "@heroicons/react/20/solid"; -import { Outlet } from "@remix-run/react"; -import { PageContainer, PageBody } from "~/components/layout/AppLayout"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { - PageHeader, - PageTitleRow, - PageTitle, - PageButtons, - PageDescription, - PageTabs, -} from "~/components/primitives/PageHeader"; -import { - newOrganizationPath, - organizationsPath, - accountPath, - logoutPath, -} from "~/utils/pathBuilder"; - -export default function Page() { - return ( - - - - - - - Logout - - - - Create new Organizations and manage your account. - - - - - - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/OrgAdminHeader.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/OrgAdminHeader.tsx deleted file mode 100644 index 073a0f276..000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/OrgAdminHeader.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { LinkButton } from "~/components/primitives/Buttons"; -import { - PageHeader, - PageTitleRow, - PageTitle, - PageButtons, - PageDescription, - PageTabs, -} from "~/components/primitives/PageHeader"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { - newProjectPath, - organizationBillingPath, - organizationPath, - organizationTeamPath, - organizationsPath, -} from "~/utils/pathBuilder"; - -export function OrgAdminHeader() { - const organization = useOrganization(); - - return ( - - - - - - Create a new project - - - - Manage your projects and team. - - - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx index a23eca60f..4282a4dc8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug._index/route.tsx @@ -1,20 +1,44 @@ import { Link } from "@remix-run/react"; import simplur from "simplur"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; import { LinkButton } from "~/components/primitives/Buttons"; import { Header3 } from "~/components/primitives/Headers"; import { NamedIcon } from "~/components/primitives/NamedIcon"; +import { + PageHeader, + PageTitleRow, + PageTitle, + PageButtons, +} from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { useOrganization } from "~/hooks/useOrganizations"; +import { Handle } from "~/utils/handle"; import { newProjectPath, projectPath } from "~/utils/pathBuilder"; -import { OrgAdminHeader } from "./OrgAdminHeader"; + +export const handle: Handle = { + breadcrumb: (match) => , +}; export default function Page() { const organization = useOrganization(); return ( - + + + + + + Create a new project + + + +