Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59277731c4 | |||
| 742d16087b | |||
| 0ab51d62bc | |||
| 756024da78 | |||
| c47ad5e038 | |||
| 888344797e | |||
| 4a7caa68b6 | |||
| 3bc54d36f7 | |||
| 5295a3f798 | |||
| 14429b8b66 | |||
| 5dd1bcc589 | |||
| ed5ee16de5 | |||
| 076185d19f | |||
| 1e07964d81 | |||
| f406e59c45 | |||
| 75550f535c | |||
| 2b48e6d04b | |||
| fe14947bb0 | |||
| 0afa119721 | |||
| af57bc208a | |||
| 7cbbb26038 | |||
| 2e5f8d8de3 | |||
| 9a7c08c26a | |||
| e8e7c116d1 | |||
| 99dd6673f9 | |||
| a41d9b3e67 | |||
| cb1825bfaf | |||
| d02173442c | |||
| 9f1f59cc81 | |||
| e48c9b5e69 | |||
| 55a9b96c88 | |||
| dc8c8edf04 | |||
| 41927edf12 | |||
| 1afba46cf0 | |||
| 32e4fc6310 | |||
| 0add95e224 | |||
| de7f133ef9 | |||
| 8b13681afc | |||
| 88baac2144 | |||
| df664ad472 | |||
| d8c53784f7 | |||
| 3c7b2de095 | |||
| ae9249405c | |||
| d63f4e2451 | |||
| f4275e5054 | |||
| 690b308fea | |||
| a74716a1b9 | |||
| 42185bc36b | |||
| 276181b91c | |||
| 578d2e5410 | |||
| 329944841f | |||
| 4a0f030ebf | |||
| 13345d0025 | |||
| d905059915 | |||
| 0d8530028f | |||
| c37e7f3030 | |||
| 6d400c3b57 | |||
| 2b8a44aed1 | |||
| 6d842b3abb | |||
| 9c6838e5c8 | |||
| 10e935b864 | |||
| 02e453c3b5 | |||
| 330ddb3311 | |||
| 14a2f4cc94 | |||
| 7e57f1f3dd | |||
| cf8f994604 | |||
| 2ae5178479 | |||
| 620b83832b | |||
| c7434125e4 | |||
| 67b5365d50 | |||
| ea25c37a10 | |||
| c1710ae72d | |||
| 9c5a7cf073 | |||
| 8eedf994af | |||
| 2c91c492df | |||
| b859abdb66 | |||
| 0876b87526 | |||
| 9c4be40adc | |||
| 2635f4bd10 | |||
| 401b7ee03d |
@@ -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=
|
||||
|
||||
@@ -52,3 +52,5 @@ apps/**/public/build
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.cosine
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"deno.enablePaths": ["references/deno-reference"]
|
||||
"deno.enablePaths": ["references/deno-reference"],
|
||||
"debug.toolBarLocation": "commandCenter"
|
||||
}
|
||||
|
||||
@@ -14,18 +14,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
# ✨🎃 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.
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# proxy
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [756024da]
|
||||
- @trigger.dev/core@2.2.7
|
||||
@@ -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 "<your-api-subdomain>/*" --env staging
|
||||
```
|
||||
|
||||
Prod:
|
||||
|
||||
```bash
|
||||
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env prod
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Set the environment variables as described above.
|
||||
|
||||
1. `pnpm install`
|
||||
2. `pnpm run dev --filter proxy`
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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_");
|
||||
}
|
||||
@@ -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<Response> {
|
||||
//check there's a private API key
|
||||
const apiKeyResult = getApiKeyFromRequest(request);
|
||||
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
|
||||
return json(
|
||||
{ error: "Invalid or Missing API key" },
|
||||
{
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//parse the request body
|
||||
try {
|
||||
const anyBody = await request.json();
|
||||
const body = SendEventBodySchema.safeParse(anyBody);
|
||||
if (!body.success) {
|
||||
return json(
|
||||
{ error: generateErrorMessage(body.error.issues) },
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The AWS SDK tries to use crypto from off of the window,
|
||||
// so we need to trick it into finding it where it expects it
|
||||
globalThis.global = globalThis;
|
||||
|
||||
const client = new SQSClient({
|
||||
region: env.AWS_SQS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const timestamp = body.data.event.timestamp ?? new Date();
|
||||
|
||||
//add the event to the queue
|
||||
const send = new SendMessageCommand({
|
||||
// use wrangler secrets to provide this global variable
|
||||
QueueUrl: env.AWS_SQS_QUEUE_URL,
|
||||
MessageBody: JSON.stringify({
|
||||
event: { ...body.data.event, timestamp },
|
||||
options: body.data.options,
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const queuedEvent = await client.send(send);
|
||||
console.log("Queued event", queuedEvent);
|
||||
|
||||
//respond with the event
|
||||
const event: ApiEventLog = {
|
||||
id: body.data.event.id,
|
||||
name: body.data.event.name,
|
||||
payload: body.data.event.payload,
|
||||
context: body.data.event.context,
|
||||
timestamp,
|
||||
deliverAt: calculateDeliverAt(body.data.options),
|
||||
};
|
||||
|
||||
return json(event, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("queueEvent error", e);
|
||||
return json(
|
||||
{
|
||||
error: `Failed to send event: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
//check there's a private API key
|
||||
const apiKeyResult = getApiKeyFromRequest(request);
|
||||
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
|
||||
return json(
|
||||
{ error: "Invalid or Missing API key" },
|
||||
{
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//parse the request body
|
||||
try {
|
||||
const anyBody = await request.json();
|
||||
const body = SendBulkEventsBodySchema.safeParse(anyBody);
|
||||
if (!body.success) {
|
||||
return json(
|
||||
{ error: generateErrorMessage(body.error.issues) },
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The AWS SDK tries to use crypto from off of the window,
|
||||
// so we need to trick it into finding it where it expects it
|
||||
globalThis.global = globalThis;
|
||||
|
||||
const client = new SQSClient({
|
||||
region: env.AWS_SQS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedEvents: ApiEventLog[] = body.data.events.map((event) => {
|
||||
const timestamp = event.timestamp ?? new Date();
|
||||
return {
|
||||
...event,
|
||||
payload: event.payload,
|
||||
timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
//divide updatedEvents into multiple batches of 10 (max size SQS accepts)
|
||||
const batches: ApiEventLog[][] = [];
|
||||
let currentBatch: ApiEventLog[] = [];
|
||||
for (let i = 0; i < updatedEvents.length; i++) {
|
||||
currentBatch.push(updatedEvents[i]);
|
||||
if (currentBatch.length === 10) {
|
||||
batches.push(currentBatch);
|
||||
currentBatch = [];
|
||||
}
|
||||
}
|
||||
if (currentBatch.length > 0) {
|
||||
batches.push(currentBatch);
|
||||
}
|
||||
|
||||
//loop through the batches and send them
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
//add the event to the queue
|
||||
const send = new SendMessageBatchCommand({
|
||||
// use wrangler secrets to provide this global variable
|
||||
QueueUrl: env.AWS_SQS_QUEUE_URL,
|
||||
Entries: batch.map((event, index) => ({
|
||||
Id: `event-${index}`,
|
||||
MessageBody: JSON.stringify({
|
||||
event,
|
||||
options: body.data.options,
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
const queuedEvent = await client.send(send);
|
||||
console.log("Queued events", queuedEvent);
|
||||
}
|
||||
|
||||
//respond with the events
|
||||
const events: ApiEventLog[] = updatedEvents.map((event) => ({
|
||||
...event,
|
||||
payload: event.payload,
|
||||
deliverAt: calculateDeliverAt(body.data.options),
|
||||
}));
|
||||
|
||||
return json(events, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("queueEvents error", e);
|
||||
return json(
|
||||
{
|
||||
error: `Failed to send events: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
name = "proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-10-30"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
[env.staging]
|
||||
[env.prod]
|
||||
@@ -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: <Story />,
|
||||
action: () => ({ redirect: "/" }),
|
||||
loader: () => ({ redirect: "/" }),
|
||||
Component: Story,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export function EndpointIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.24336 5.11182C8.75159 4.60359 9.57559 4.60359 10.0838 5.11182L18.8882 13.9162C19.3964 14.4244 19.3964 15.2484 18.8882 15.7566L17.1469 17.4979C16.453 18.2196 15.6222 18.7956 14.7031 19.1922C13.7839 19.5889 12.7949 19.7982 11.7939 19.8081C10.7929 19.8179 9.79992 19.628 8.87318 19.2494C7.94643 18.8708 7.10449 18.3113 6.39662 17.6034C5.68875 16.8955 5.12916 16.0536 4.7506 15.1268C4.37205 14.2001 4.18212 13.2071 4.19194 12.2061C4.20176 11.2051 4.41112 10.2161 4.80779 9.29691C5.20445 8.37777 5.78044 7.54697 6.50206 6.85312L8.24336 5.11182Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.32398 20.676L7.11974 16.8803"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.2535 3.32398L11.4578 7.11974"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20.676 8.74649L16.8803 12.5423"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.16549"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
|
||||
export function IntegrationIcon() {
|
||||
return <LogoIcon className="h-3.5 w-3.5 flex-none pb-0.5" />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function RunsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.2964 3.66962C11.97 3.83662 11.6961 4.09048 11.5047 4.40323C11.3134 4.71598 11.2121 5.07547 11.212 5.44211L11.211 8.0162L9.22047 8.0172C8.95637 8.0172 8.70309 8.12211 8.51635 8.30886C8.3296 8.4956 8.22469 8.74888 8.22469 9.01298V14.9877L8.23166 15.1042C8.26019 15.3464 8.37664 15.5697 8.5589 15.7318C8.74116 15.8939 8.97657 15.9834 9.22047 15.9834L11.211 15.9825L11.212 18.5585C11.2121 18.9524 11.329 19.3373 11.5478 19.6648C11.7666 19.9922 12.0777 20.2474 12.4415 20.3981C12.8054 20.5488 13.2057 20.5882 13.592 20.5114C13.9783 20.4346 14.3331 20.245 14.6116 19.9666L21.1698 13.4084C21.5432 13.0349 21.7529 12.5284 21.7529 12.0003C21.7529 11.4722 21.5432 10.9658 21.1698 10.5923L14.6116 4.03408C14.3331 3.75539 13.9782 3.56558 13.5918 3.48865C13.2054 3.41172 12.8048 3.45114 12.4408 3.60191L12.2964 3.66962Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M3.24578 8.0172C3.48968 8.01723 3.72509 8.10677 3.90735 8.26885C4.08961 8.43092 4.20606 8.65425 4.23459 8.89647L4.24156 9.01298V14.9877C4.24128 15.2415 4.1441 15.4856 3.96986 15.6701C3.79563 15.8547 3.55751 15.9658 3.30414 15.9806C3.05077 15.9955 2.80128 15.9131 2.60666 15.7502C2.41203 15.5873 2.28695 15.3562 2.25697 15.1042L2.25 14.9877V9.01298C2.25 8.74888 2.35491 8.4956 2.54166 8.30885C2.7284 8.12211 2.98168 8.0172 3.24578 8.0172Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.23312 8.0172C6.47702 8.01723 6.71243 8.10677 6.89469 8.26885C7.07696 8.43092 7.1934 8.65425 7.22193 8.89647L7.2289 9.01298V14.9877C7.22862 15.2415 7.13144 15.4856 6.95721 15.6701C6.78298 15.8547 6.54485 15.9658 6.29148 15.9806C6.03811 15.9955 5.78863 15.9131 5.594 15.7502C5.39937 15.5873 5.27429 15.3562 5.24431 15.1042L5.23734 14.9877V9.01298C5.23734 8.74888 5.34226 8.4956 5.529 8.30885C5.71575 8.12211 5.96903 8.0172 6.23312 8.0172Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid h-8 grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 px-4 text-center">
|
||||
<span></span>
|
||||
<Paragraph variant="small">
|
||||
You are impersonating <span className="font-semibold">{impersonationId}</span>
|
||||
</Paragraph>
|
||||
<Form
|
||||
action="/resources/impersonation"
|
||||
method="delete"
|
||||
reloadDocument
|
||||
className="justify-self-end"
|
||||
>
|
||||
<div className="w-full">
|
||||
<Form action="/resources/impersonation" method="delete" reloadDocument className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
className="!text-slate-700 transition hover:!text-slate-900"
|
||||
variant="secondary/small"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={UserMinusIcon}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Stop impersonating
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 { Dialog, DialogContent, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { useState } from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { generateTwoRandomWords } from "~/utils/randomWords";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
@@ -25,11 +26,16 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="tertiary/small" leadingIconClassName="text-dimmed" LeadingIcon="key">
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
leadingIconClassName="text-dimmed"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
@@ -41,7 +47,7 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({ id, title, randomWord, closeModal }: ModalContentProps) => {
|
||||
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
@@ -52,55 +58,47 @@ const RegenerateApiKeyModalContent = ({ id, title, randomWord, closeModal }: Mod
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="flex justify-center">
|
||||
<Header1>{title} Environment</Header1>
|
||||
<div className="flex w-full flex-col items-center gap-y-5 pb-4">
|
||||
<div className="mt-3 flex gap-x-3 rounded-md border border-ui-border py-4 pl-3 pr-5">
|
||||
<ExclamationTriangleIcon className="relative top-1 h-6 w-6 min-w-[2rem] text-amber-500" />
|
||||
<Paragraph>
|
||||
Regenerating the keys for this environment will temporarily break any live Jobs in the
|
||||
<span className="text-bright"> {title} Environment</span> until the new API keys are set
|
||||
in the relevant environment variables.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Header2 className="rounded border border-rose-500 bg-rose-500/10 px-3.5 py-2 text-center text-bright">
|
||||
Are you sure you want to regenerate the keys <br />
|
||||
for this environment?
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="px-6 text-center">
|
||||
<>
|
||||
This will temporarily break any live jobs in the
|
||||
<span className="strong text-bright"> {title} Environment</span> until the new API keys
|
||||
are set in the relevant environment variables.
|
||||
</>
|
||||
</Paragraph>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${id}/regenerate-api-key`}
|
||||
className="mt-2 flex w-full flex-col items-center gap-2"
|
||||
>
|
||||
<Paragraph variant="small" className="font-medium">
|
||||
To confirm, please enter the text:{" "}
|
||||
<span className="font-bold text-bright">{randomWord}</span>
|
||||
</Paragraph>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation Text"
|
||||
fullWidth={false}
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary/large"
|
||||
fullWidth
|
||||
className="mt-4"
|
||||
disabled={confirmationText !== randomWord}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner color="white" />
|
||||
) : (
|
||||
<>
|
||||
<NamedIcon
|
||||
name="key"
|
||||
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
|
||||
/>
|
||||
Regenerate API Keys
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-ui-border bg-slate-900 px-2 py-1 font-mono text-bright"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
className="rounded-r-none"
|
||||
variant="large"
|
||||
/>
|
||||
<Button
|
||||
variant="primary/large"
|
||||
disabled={confirmationText !== randomWord}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isSubmitting ? <Spinner color="white" /> : <>Regenerate</>}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<PageGradient>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="mb-6 grid place-items-center pb-6">{children}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>{frameworkName} is coming soon!</Header1>
|
||||
<LinkButton
|
||||
to={projectSetupPath(organization, project)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={Squares2X2Icon}
|
||||
>
|
||||
Choose a different framework
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph spacing className="border-b border-slate-800 pb-4">
|
||||
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.
|
||||
</Paragraph>
|
||||
<a
|
||||
href={githubIssueUrl}
|
||||
target="_blank"
|
||||
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
|
||||
<div className="mx-auto max-w-3xl pt-16">
|
||||
<div className="mb-6 grid place-items-center pb-6">{children}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>{frameworkName} is coming soon!</Header1>
|
||||
<LinkButton
|
||||
to={projectSetupPath(organization, project)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={Squares2X2Icon}
|
||||
>
|
||||
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
|
||||
<h2 className="text-2xl font-semibold text-black">
|
||||
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
support for {frameworkName}
|
||||
</h2>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
<GitHubDarkIcon className="h-4 w-4" />
|
||||
<Paragraph variant="small">View on GitHub</Paragraph>
|
||||
<NamedIcon
|
||||
name="arrow-right"
|
||||
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
Choose a different framework
|
||||
</LinkButton>
|
||||
</div>
|
||||
</PageGradient>
|
||||
<Paragraph spacing className="border-b border-slate-800 pb-4">
|
||||
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.
|
||||
</Paragraph>
|
||||
<a
|
||||
href={githubIssueUrl}
|
||||
target="_blank"
|
||||
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
|
||||
>
|
||||
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
|
||||
<h2 className="text-2xl font-semibold text-black">
|
||||
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
support for {frameworkName}
|
||||
</h2>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
<GitHubDarkIcon className="h-4 w-4" />
|
||||
<Paragraph variant="small">View on GitHub</Paragraph>
|
||||
<NamedIcon
|
||||
name="arrow-right"
|
||||
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<PageGradient>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>Choose a framework to get started…</Header1>
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
Request a framework
|
||||
</Button>
|
||||
}
|
||||
defaultValue="feature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 place-items-center gap-2">
|
||||
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
|
||||
<NextjsLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
|
||||
<ExpressLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
|
||||
<RemixLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
|
||||
<AstroLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
|
||||
<RedwoodLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
|
||||
<NuxtLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)} supported>
|
||||
<SvelteKitLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
|
||||
<FastifyLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNestjsPath(organization, project)} supported>
|
||||
<NestjsLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
</div>
|
||||
<div className="mx-auto max-w-3xl pt-28">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header1 spacing>Choose a framework to get started…</Header1>
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="tertiary/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
Request a framework
|
||||
</Button>
|
||||
}
|
||||
defaultValue="feature"
|
||||
/>
|
||||
</div>
|
||||
</PageGradient>
|
||||
|
||||
<div className="grid grid-cols-3 place-items-center gap-2">
|
||||
<FrameworkLink to={projectSetupNextjsPath(organization, project)} supported>
|
||||
<NextjsLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupExpressPath(organization, project)} supported>
|
||||
<ExpressLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRemixPath(organization, project)} supported>
|
||||
<RemixLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupAstroPath(organization, project)} supported>
|
||||
<AstroLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupSvelteKitPath(organization, project)} supported>
|
||||
<SvelteKitLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNestjsPath(organization, project)} supported>
|
||||
<NestjsLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupFastifyPath(organization, project)}>
|
||||
<FastifyLogo className="w-36" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupNuxtPath(organization, project)}>
|
||||
<NuxtLogo className="w-32" />
|
||||
</FrameworkLink>
|
||||
<FrameworkLink to={projectSetupRedwoodPath(organization, project)}>
|
||||
<RedwoodLogo className="w-44" />
|
||||
</FrameworkLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Paragraph spacing>
|
||||
HTTP endpoints allow you to trigger your Jobs from any webhooks. They require a bit more
|
||||
work than using <TextLink to={docsPath("integrations/introduction")}>Integrations</TextLink>{" "}
|
||||
but allow you to connect to any API.
|
||||
</Paragraph>
|
||||
<Header2 spacing>Getting started</Header2>
|
||||
<Paragraph spacing>
|
||||
You need to define the HTTP endpoint in your code. To do this you use{" "}
|
||||
<InlineCode>client.defineHttpEndpoint()</InlineCode>. This will create an HTTP endpoint.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
Then you can create a Trigger from this by calling <InlineCode>.onRequest()</InlineCode> on
|
||||
the created HTTP endpoint.
|
||||
</Paragraph>
|
||||
<Callout variant="docs" to={docsPath("documentation/concepts/http-endpoints")}>
|
||||
Read the HTTP endpoints guide to learn more.
|
||||
</Callout>
|
||||
<Header2 spacing className="mt-4">
|
||||
An example: cal.com
|
||||
</Header2>
|
||||
<CodeBlock
|
||||
code={`//create an HTTP endpoint
|
||||
const caldotcom = client.defineHttpEndpoint({
|
||||
id: "cal.com",
|
||||
source: "cal.com",
|
||||
icon: "caldotcom",
|
||||
verify: async (request) => {
|
||||
//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 (
|
||||
<>
|
||||
<Header2 spacing>Setting up your webhook</Header2>
|
||||
<Paragraph spacing>
|
||||
To start receiving data you need to enter the Endpoint URL and secret into the API service
|
||||
you want to receive webhooks from.
|
||||
</Paragraph>
|
||||
|
||||
<StepNumber stepNumber="1" title={<>Go to the relevant API dashboard</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="2" title={<>Copy the Webhook URL and Secret</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
A unique Webhook URL is created for each environment (Dev, Staging, and Prod). Jobs will
|
||||
only be triggered from the relevant environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<StepNumber stepNumber="3" title={<>Add the Secret to your Environment variables</>} />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>
|
||||
Use the secret in the <InlineCode>verify()</InlineCode> 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.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<Header2 spacing>Triggering runs</Header2>
|
||||
<StepNumber stepNumber="1" title="Ensure you're using the HTTP Endpoint in your code" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
In your code, you should use the <InlineCode>.onRequest()</InlineCode> function in a Job
|
||||
Trigger. You can filter so only data that matches your criteria triggers the Job.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Make sure your code is deployed (for Staging and Prod)" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
If you're using the Staging or Prod environment, you need to make sure your code is
|
||||
deployed. Deploy like you normally would –{" "}
|
||||
<TextLink to={docsPath("documentation/guides/deployment")}>
|
||||
read our deployment guide
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Perform an action that sends a webhook" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
|
||||
<Callout variant="docs" to={docsPath("documentation/concepts/http-endpoints")}>
|
||||
Read the HTTP endpoints guide to learn more.
|
||||
</Callout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Paragraph variant="small">{integration.description}</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<LinkButton
|
||||
to={apiReferencePath(integration.identifier)}
|
||||
variant="secondary/small"
|
||||
TrailingIcon="arrow-up-right"
|
||||
trailingIconClassName="h-4 w-4 text-slate-400"
|
||||
target="_blank"
|
||||
>
|
||||
View examples
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsIntegrationPath(integration.identifier)}
|
||||
variant="secondary/small"
|
||||
@@ -83,13 +92,6 @@ export function ConnectToIntegrationSheet({
|
||||
variant="description"
|
||||
/>
|
||||
)}
|
||||
<RadioGroupItem
|
||||
id="custom"
|
||||
value="custom"
|
||||
label="Fetch/Existing SDK"
|
||||
description={`Alternatively, use ${integration.name} without our integration.`}
|
||||
variant="description"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
{integrationMethod && (
|
||||
@@ -132,7 +134,5 @@ function SelectedIntegrationMethod({
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
);
|
||||
case "custom":
|
||||
return <CustomHelp name={integration.name} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-4">
|
||||
<Header1 className="mb-2">You can use any API with requests or an SDK</Header1>
|
||||
<Header2 className="mb-2">How to use an SDK</Header2>
|
||||
<Header1 className="mb-2">Using an API with an SDK or requests</Header1>
|
||||
<Paragraph spacing>
|
||||
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{" "}
|
||||
<TextLink href="https://trigger.dev/docs/documentation/concepts/http-endpoints">
|
||||
HTTP endpoints
|
||||
</TextLink>{" "}
|
||||
and perform actions by wrapping tasks using{" "}
|
||||
<TextLink
|
||||
href="https://trigger.dev/docs/documentation/guides/writing-jobs-step-by-step#create-your-own-tasks"
|
||||
className="font-mono"
|
||||
>
|
||||
io.runTask
|
||||
</TextLink>
|
||||
. This makes your background job resumable and appear in our dashboard.
|
||||
</Paragraph>
|
||||
<Paragraph spacing>Here's an example with the official GitHub SDK</Paragraph>
|
||||
<CodeBlock
|
||||
code={`
|
||||
client.defineJob({
|
||||
id: "scheduled-job-1",
|
||||
name: "Scheduled Job 1",
|
||||
version: "0.1.1",
|
||||
trigger: cronTrigger({
|
||||
cron: "*/5 * * * *", // every 5 minutes
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
//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"
|
||||
/>
|
||||
<Header2 className="mb-2">How to use fetch</Header2>
|
||||
<Paragraph spacing>
|
||||
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.
|
||||
</Paragraph>
|
||||
<CodeBlock
|
||||
code={`
|
||||
client.defineJob({
|
||||
id: "scheduled-job-1",
|
||||
name: "Scheduled Job 1",
|
||||
version: "0.1.1",
|
||||
trigger: cronTrigger({
|
||||
cron: "*/5 * * * *", // every 5 minutes
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
//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 ? (
|
||||
<>
|
||||
<Header2 className="mb-2">Example {api.name} code</Header2>
|
||||
<Paragraph spacing className="mb-4">
|
||||
This is how you can use {api.name} with Trigger.dev. This code can be copied and
|
||||
modified to suit your use-case.
|
||||
</Paragraph>
|
||||
{api.examples.length > 1 && (
|
||||
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
{api.examples?.map((example, index) => (
|
||||
<button
|
||||
onClick={() => changeCodeExample(index)}
|
||||
key={example.codeUrl}
|
||||
className={cn(
|
||||
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
|
||||
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
|
||||
)}
|
||||
>
|
||||
{example.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CodeExample example={api.examples[selectedExample]} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header2 className="mb-2">Example code using fetch / an existing SDK</Header2>
|
||||
<Paragraph spacing className="mb-4">
|
||||
You can use one of our examples below as a starting point / reference for your projects.
|
||||
Please{" "}
|
||||
<Feedback
|
||||
button={
|
||||
<span className="cursor-pointer text-indigo-500 transition duration-300 hover:text-indigo-400">
|
||||
reach out to us
|
||||
</span>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>{" "}
|
||||
if you're having any issues.
|
||||
</Paragraph>
|
||||
|
||||
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700 sm:h-full">
|
||||
{fallbackExamples.map((example, index) => (
|
||||
<button
|
||||
onClick={() => changeCodeExample(index)}
|
||||
key={example.codeUrl}
|
||||
className={cn(
|
||||
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
|
||||
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
|
||||
)}
|
||||
>
|
||||
{example.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CodeExample example={fallbackExamples[selectedExample]} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<NamedIconInBox name={api.identifier} className="h-9 w-9" />
|
||||
<Header1>{api.name}</Header1>
|
||||
</div>
|
||||
{requested ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
<Paragraph variant="small">
|
||||
We'll let you know when the Integration is available.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<fetcher.Form method="post" action={`/resources/apivote/${api.identifier}`}>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
{isLoading ? "Saving…" : `I want an Integration for ${api.name}`}
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<CustomHelp name={api.name} />
|
||||
<CustomHelp api={api} />
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -40,7 +40,7 @@ export function DeleteJobDialogContent({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
|
||||
<Header1>{title}</Header1>
|
||||
<Paragraph variant="small">ID: {slug}</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Table>
|
||||
<Table containerClassName="mb-4">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Job</TableHeaderCell>
|
||||
@@ -45,7 +44,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableBody>
|
||||
{jobs.length > 0 ? (
|
||||
jobs.map((job) => {
|
||||
const path = jobPath(organization, project, job);
|
||||
const path = jobPath(organization, { slug: job.projectSlug }, job);
|
||||
return (
|
||||
<TableRow key={job.id} className="group">
|
||||
<TableCell to={path}>
|
||||
@@ -153,26 +152,25 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableCellMenu isSticky>
|
||||
<PopoverMenuItem to={path} title="View Job" icon="eye" />
|
||||
<PopoverMenuItem
|
||||
to={jobTestPath(organization, project, job)}
|
||||
to={jobTestPath(organization, { slug: job.projectSlug }, job)}
|
||||
title="Test Job"
|
||||
icon="beaker"
|
||||
/>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="menu-item" LeadingIcon="trash-can">
|
||||
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs">
|
||||
Delete Job
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogHeader>Delete Job</DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCellMenu>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function AppContainer({
|
||||
}) {
|
||||
return (
|
||||
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
|
||||
<div className={cn("grid h-full w-full grid-rows-[2.75rem_auto]")}>{children}</div>
|
||||
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
|
||||
</BackgroundGradient>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export function PageContainer({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("grid h-full grid-rows-[auto_1fr]", className)}>{children}</div>;
|
||||
return (
|
||||
<div className={cn("grid h-full grid-rows-[auto_1fr] overflow-hidden", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBody({
|
||||
@@ -58,7 +62,15 @@ export function PageBody({
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(scrollable ? "overflow-y-auto p-4 " : "overflow-hidden")}>{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
: "overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +80,8 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export function MainCenteredContainer({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mx-auto mt-[30vh] max-w-xs">{children}</div>
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mx-auto mt-[25vh] max-w-xs overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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: UIMatch, allMatches: UIMatch[]) => ReactNode;
|
||||
|
||||
@@ -9,19 +10,26 @@ export function Breadcrumb() {
|
||||
const matches = useMatches() as UIMatch<unknown, Handle>[];
|
||||
|
||||
return (
|
||||
<div className="hidden items-center md:flex">
|
||||
{matches.map((match) => {
|
||||
if (!match.handle || !match.handle.breadcrumb) return null;
|
||||
<div className="flex items-center px-1">
|
||||
{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 (
|
||||
<Fragment key={match.id}>
|
||||
<BreadcrumbIcon />
|
||||
{breadcrumb(match, matches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<Fragment key={match.id}>
|
||||
{index !== 0 && <BreadcrumbIcon />} {breadcrumb(match, matches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
return (
|
||||
<LinkButton to={to} variant="tertiary/small">
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Link, UIMatch } 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: UIMatch[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const organization = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
const projectJobs = useJobs(matches);
|
||||
const currentJob = useJob(matches);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverArrowTrigger isOpen={isOpen}>
|
||||
{currentJob?.title ?? "Select a job"}
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
collisionPadding={20}
|
||||
>
|
||||
<PopoverSectionHeader title="Jobs" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{projectJobs.map((job) => {
|
||||
const isSelected = job.id === currentJob?.id;
|
||||
return (
|
||||
<Link
|
||||
key={job.id}
|
||||
to={jobPath(organization, project, job)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md p-2 transition hover:bg-slate-800",
|
||||
isSelected && "bg-slate-750 group-hover:bg-slate-750"
|
||||
)}
|
||||
>
|
||||
<NamedIcon name={job.event.icon} className="h-8 w-8" />
|
||||
<LabelValueStack
|
||||
label={job.title}
|
||||
value={job.slug}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
/>
|
||||
{isSelected && <NamedIcon name="check" className="mr-1 h-6 w-6" />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="z-50 flex w-full items-center justify-between gap-2 border-b border-uiBorder py-1 pl-1 pr-2.5">
|
||||
<div className="flex gap-0.5">
|
||||
<Link to="/" className="p-2">
|
||||
<LogoIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
<LinkButton to={docsRoot()} variant="secondary/small" LeadingIcon={BookOpenIcon}>
|
||||
Documentation
|
||||
</LinkButton>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ChatBubbleLeftRightIcon}
|
||||
shortcut={{ key: "f" }}
|
||||
>
|
||||
Help & feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
return (
|
||||
<LinkButton to={to} variant="tertiary/small">
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
@@ -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 <Spinner color="muted" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
@@ -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 <div className="flex h-full w-full justify-stretch overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
variants={menuVariants}
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
transition={{ type: "spring", duration: 0.5 }}
|
||||
className={cn(
|
||||
"flex h-full flex-col justify-between overflow-hidden border-r border-uiBorder p-1 transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
to={projectPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
forceActive={jobsActive}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={projectIntegrationsPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
hasWarning={project.hasUnconfiguredIntegrations}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
data-action="triggers"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon="team"
|
||||
to={organizationTeamPath(organization)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Usage & Billing"
|
||||
icon="billing"
|
||||
to={organizationBillingPath(organization)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Onboarding"
|
||||
icon="clipboard-checked"
|
||||
to={projectSetupPath(organization, project)}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="onboarding"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="list"
|
||||
to="https://trigger.dev/changelog"
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Account"
|
||||
icon={UserProfilePhoto}
|
||||
to={accountPath()}
|
||||
isCollapsed={isCollapsed}
|
||||
data-action="account"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
},
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
name,
|
||||
to,
|
||||
isCollapsed,
|
||||
forceActive,
|
||||
hasWarning = false,
|
||||
target,
|
||||
}: {
|
||||
icon: IconNames | React.ComponentType<any>;
|
||||
name: string;
|
||||
to: string;
|
||||
isCollapsed: boolean;
|
||||
hasWarning?: boolean;
|
||||
forceActive?: boolean;
|
||||
target?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<NavLinkButton
|
||||
variant="menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
to={to}
|
||||
target={target}
|
||||
className={({ isActive, isPending }) => {
|
||||
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"
|
||||
);
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
className="shrink-0 pl-1"
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
variants={itemVariants}
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
>
|
||||
{name}
|
||||
</motion.span>
|
||||
{hasWarning && <NamedIcon name="error" className="absolute left-1 top-1 h-4 w-4" />}
|
||||
</NavLinkButton>
|
||||
}
|
||||
content={name}
|
||||
side="right"
|
||||
hidden={!isCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { UIMatch } 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: UIMatch[] }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const organizations = useOrganizations(matches);
|
||||
const isNewOrgPage = useIsNewOrganizationPage(matches);
|
||||
const currentProject = useOptionalProject(matches);
|
||||
|
||||
if (isNewOrgPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverArrowTrigger isOpen={isOpen}>
|
||||
{currentProject?.name ?? "Select a project"}
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
collisionPadding={20}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1 ">
|
||||
{organization.projects.map((project) => {
|
||||
const isSelected = project.id === currentProject?.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={project.id}
|
||||
to={projectPath(organization, project)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between pl-1 text-bright">
|
||||
<span className="grow truncate text-left">{project.name}</span>
|
||||
<Badge className="mr-0.5">{simplur`${project._count.jobs} job[|s]`}</Badge>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New Project"
|
||||
isSelected={false}
|
||||
icon="plus"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="border-t border-slate-700 p-1">
|
||||
<PopoverMenuItem
|
||||
to={newOrganizationPath()}
|
||||
title="New Organization"
|
||||
isSelected={false}
|
||||
icon="plus"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<User, "email" | "admin"> & { 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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-background px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
icon="plus"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
count={project.jobCount}
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
iconColor="text-amber-500"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
data-action="triggers"
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
iconColor="text-green-500"
|
||||
count={project.httpEndpointCount}
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
iconColor="text-rose-500"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={organization.title}>
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
title="Invite team member"
|
||||
icon={UserPlusIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon="folder"
|
||||
to={organizationPath(organization)}
|
||||
data-action="projects"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
to={organizationTeamPath(organization)}
|
||||
iconColor="text-sky-500"
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name={isManagedCloud ? "Usage & Billing" : "Usage"}
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-pink-500"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isOrgMenuOpen}
|
||||
overflowHidden
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{organization.title ?? "Select an organization"}</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="border-t border-slate-800 p-1">
|
||||
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon="plus" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
|
||||
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1">
|
||||
<UserProfilePhoto
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-600",
|
||||
user.isImpersonating && "rounded-full border border-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<PopoverSectionHeader title={user.email} variant="extra-small" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{user.isImpersonating && <ImpersonationBanner />}
|
||||
{user.admin && (
|
||||
<PopoverMenuItem
|
||||
to={"/admin"}
|
||||
title="Admin"
|
||||
icon={AcademicCapIcon}
|
||||
leadingIconClassName="text-yellow-500"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={accountPath()}
|
||||
title="View profile"
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Log out"
|
||||
icon={ArrowRightOnRectangleIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between overflow-hidden">
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
@@ -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<any> | IconNamesOrString;
|
||||
TrailingIcon?: React.ComponentType<any> | 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) {
|
||||
<div
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center gap-x-0.5"
|
||||
"flex w-full items-center",
|
||||
iconSpacingClassName
|
||||
)}
|
||||
>
|
||||
{LeadingIcon &&
|
||||
@@ -170,7 +202,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
|
||||
{text &&
|
||||
(typeof text === "string" ? (
|
||||
<span className={cn("mx-auto grow self-center truncate px-1", textColorClassName)}>
|
||||
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -43,12 +43,12 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 p-6 pt-11 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<hr className="-ml-6 w-[calc(100%_+_3rem)]" />
|
||||
<hr className="absolute left-0 top-12 w-full" />
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
|
||||
<div className="flex gap-x-2">
|
||||
@@ -73,7 +73,7 @@ const DialogContent = React.forwardRef<
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
<div className={cn("flex flex-col text-left font-medium text-dimmed", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function FormTitle({
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex flex-col gap-3 pb-4",
|
||||
divide ? "border-b border-uiBorder" : "",
|
||||
divide ? "border-b border-ui-border" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -88,12 +88,7 @@ export function HelpContent({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grow rounded border border-slate-850 bg-midnight-850 bg-contain bg-left-top bg-no-repeat p-6"
|
||||
style={{
|
||||
backgroundImage: `url(${gradientPath})`,
|
||||
}}
|
||||
>
|
||||
<div className="grow rounded border border-slate-850 bg-midnight-850/80 bg-contain bg-left-top bg-no-repeat p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<Paragraph variant={variation.label}>{label}</Paragraph>
|
||||
<>
|
||||
{href ? (
|
||||
<SimpleTooltip
|
||||
side="bottom"
|
||||
button={
|
||||
<Paragraph variant={variation.value}>
|
||||
<a href={href} className="underline underline-offset-2" target="_blank">
|
||||
{value}
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
|
||||
</a>
|
||||
</Paragraph>
|
||||
}
|
||||
content={href}
|
||||
/>
|
||||
<ValueButton value={value} href={href} variant={variant} />
|
||||
) : (
|
||||
<Paragraph variant={variation.value}>{value}</Paragraph>
|
||||
)}
|
||||
@@ -64,3 +54,40 @@ export function LabelValueStack({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Paragraph variant={variation.value}>
|
||||
<Link to={href} reloadDocument className="underline underline-offset-2">
|
||||
{value}
|
||||
</Link>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
side="bottom"
|
||||
button={
|
||||
<Paragraph variant={variation.value}>
|
||||
<a href={href} className="underline underline-offset-2" target="_blank">
|
||||
{value}
|
||||
<ArrowTopRightOnSquareIcon className="ml-1 inline-block h-4 w-4 text-dimmed" />
|
||||
</a>
|
||||
</Paragraph>
|
||||
}
|
||||
content={href}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
<UserPlusIcon className={cn("text-indigo-500", className)} />
|
||||
),
|
||||
job: (className: string) => <WrenchScrewdriverIcon className={cn("text-teal-500", className)} />,
|
||||
job: (className: string) => (
|
||||
<WrenchScrewdriverIcon className={cn("text-indigo-500", className)} />
|
||||
),
|
||||
key: (className: string) => <KeyIcon className={cn("text-amber-400", className)} />,
|
||||
lightbulb: (className: string) => <LightBulbIcon className={cn("text-amber-400", className)} />,
|
||||
"clipboard-checked": (className: string) => (
|
||||
@@ -183,6 +187,11 @@ const icons = {
|
||||
<ScheduleIcon className={cn("text-sky-500", className)} />
|
||||
),
|
||||
webhook: (className: string) => <WebhookIcon className={cn("text-pink-500", className)} />,
|
||||
endpoint: (className: string) => <EndpointIcon className={cn("text-blue-500", className)} />,
|
||||
"http-endpoint": (className: string) => (
|
||||
<GlobeAltIcon className={cn("text-blue-500", className)} />
|
||||
),
|
||||
runs: (className: string) => <RunsIcon className={cn("text-lime-500", className)} />,
|
||||
};
|
||||
|
||||
export type IconNames = keyof typeof icons;
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-uiBorder pb-4")}>
|
||||
<div className={cn("mx-4 pt-4", hideBorder ? "" : "border-b border-ui-border pb-4")}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
{backButton && (
|
||||
@@ -48,7 +52,10 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
|
||||
<BreadcrumbIcon className="h-6" />
|
||||
</div>
|
||||
)}
|
||||
<Header1>{title}</Header1>
|
||||
<Header1 className="flex items-center gap-1">
|
||||
{icon && <Icon icon={icon} className="h-5 w-5" />}
|
||||
{title}
|
||||
</Header1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +96,28 @@ export function PageInfoProperty({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
to,
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
value: React.ReactNode;
|
||||
to?: string;
|
||||
}) {
|
||||
if (to === undefined) {
|
||||
return <PageInfoPropertyContent icon={icon} label={label} value={value} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkButton variant="tertiary/small" to={to} TrailingIcon={ArrowUpRightIcon}>
|
||||
<PageInfoPropertyContent icon={icon} label={label} value={value} />
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PageInfoPropertyContent({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
function PopoverSectionHeader({ title }: { title: string }) {
|
||||
function PopoverSectionHeader({
|
||||
title,
|
||||
variant = "extra-extra-small/dimmed/caps",
|
||||
}: {
|
||||
title: string;
|
||||
variant?: ParagraphVariant;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="extra-extra-small/bright/caps" className="bg-slate-900 px-2.5 py-2">
|
||||
<Paragraph variant={variant} className="bg-slate-900 px-2.5 py-1.5">
|
||||
{title}
|
||||
</Paragraph>
|
||||
);
|
||||
@@ -45,28 +52,33 @@ function PopoverMenuItem({
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
}: {
|
||||
to: string;
|
||||
icon: string;
|
||||
icon: string | React.ComponentType<any>;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={to}
|
||||
variant="menu-item"
|
||||
variant={variant.variant}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={leadingIconClassName}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
TrailingIcon={isSelected ? "check" : undefined}
|
||||
className={isSelected ? "bg-slate-750 group-hover:bg-slate-750" : undefined}
|
||||
className={isSelected ? "bg-slate-750 group-hover:bg-slate-800" : undefined}
|
||||
>
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
function PopoverCustomTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
className,
|
||||
@@ -76,15 +88,50 @@ function PopoverArrowTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
"group flex items-center justify-end gap-1 rounded text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Paragraph variant="extra-small" className="transition group-hover:text-bright">
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
fullWidth = false,
|
||||
overflowHidden = false,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
fullWidth?: boolean;
|
||||
overflowHidden?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-dimmed transition hover:bg-slate-850 hover:text-bright",
|
||||
fullWidth && "w-full justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className={cn(
|
||||
"flex transition group-hover:text-bright",
|
||||
overflowHidden && "overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Paragraph>
|
||||
<ChevronDownIcon
|
||||
className={cn("h-3 w-3 transition group-hover:text-bright", isOpen && "-rotate-180")}
|
||||
className={cn(
|
||||
"h-3 w-3 min-w-[0.75rem] text-slate-600 transition group-hover:text-bright",
|
||||
isOpen && "-rotate-180"
|
||||
)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
);
|
||||
@@ -113,7 +160,8 @@ export {
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverSectionHeader,
|
||||
PopoverCustomTrigger,
|
||||
PopoverArrowTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
PopoverMenuItem,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="grid max-h-full grid-rows-[2.75rem_1fr] overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-uiBorder p-2">
|
||||
<div className="flex items-center gap-2 border-b border-ui-border p-2">
|
||||
<SheetPrimitive.Close className="rounded-sm p-1 transition hover:bg-slate-800 disabled:pointer-events-none">
|
||||
<NamedIcon name="close" className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
@@ -181,7 +181,7 @@ export const SheetBody = ({ className, ...props }: React.HTMLAttributes<HTMLDivE
|
||||
export const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-uiBorder py-3.5",
|
||||
"mx-4 flex shrink-0 items-center gap-4 border-b border-ui-border py-3.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -194,7 +194,7 @@ export const SheetFooter = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("shrink-0", className)} {...props}>
|
||||
<div className="mx-4 border-t border-uiBorder py-3">{children}</div>
|
||||
<div className="mx-4 border-t border-ui-border py-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-uiBorder scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-ui-border scrollbar-thin scrollbar-track-midnight-850 scrollbar-thumb-slate-700",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
return (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-uiBorder bg-slate-850", className)}
|
||||
className={cn("rounded-t-md", "relative divide-y divide-ui-border bg-slate-850", className)}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
@@ -55,7 +55,7 @@ type TableBodyProps = {
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className, children }, ref) => {
|
||||
return (
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-uiBorder", className)}>
|
||||
<tbody ref={ref} className={cn("relative divide-y divide-ui-border", className)}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-3 align-middle text-xs font-normal uppercase tracking-wider text-dimmed",
|
||||
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-dimmed",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export type TabsProps = {
|
||||
|
||||
export function Tabs({ tabs, className }: TabsProps) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-uiBorder`, className)}>
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
<NavLink key={index} to={tab.to} className="group flex flex-col items-center pt-1" end>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
<div className="grid h-full grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-6 overflow-y-auto py-4 pl-4 pr-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div>
|
||||
{run.status === "SUCCESS" &&
|
||||
(run.tasks.length === 0 || run.tasks.every((t) => t.noop)) && (
|
||||
<Callout
|
||||
variant={"warning"}
|
||||
to="https://trigger.dev/docs/documentation/concepts/tasks"
|
||||
className="mb-4"
|
||||
>
|
||||
This Run completed but it did not use any Tasks – this can cause unpredictable
|
||||
results. Read the docs to view the solution.
|
||||
</Callout>
|
||||
)}
|
||||
<Header2 className="mb-2">Trigger</Header2>
|
||||
<RunPanel
|
||||
selected={selectedId === "trigger"}
|
||||
@@ -251,7 +263,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
<CodeBlock language="json" code={run.output} maxLines={10} />
|
||||
) : (
|
||||
run.output === null && (
|
||||
<Paragraph variant="small">This Run returned nothing.</Paragraph>
|
||||
<Paragraph variant="small" className="mt-4">
|
||||
This Run returned nothing.
|
||||
</Paragraph>
|
||||
)
|
||||
)}
|
||||
</RunPanelBody>
|
||||
@@ -262,7 +276,7 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
|
||||
{/* Detail view */}
|
||||
<div className="overflow-y-auto py-4 pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<Header2 className="mb-2">Detail</Header2>
|
||||
<Header2 className="mb-2">Details</Header2>
|
||||
{selectedId ? <Outlet /> : <Callout variant="info">Select a task or trigger</Callout>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,50 +341,52 @@ function RerunPopover({
|
||||
Rerun Job
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
|
||||
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
|
||||
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
|
||||
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
|
||||
{environmentType === "PRODUCTION" && (
|
||||
<Callout variant="warning" className="mb-2">
|
||||
This will rerun this Job in your Production environment.
|
||||
</Callout>
|
||||
<div className="px-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
This will rerun this Job in your Production environment.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-4 divide-y divide-slate-600">
|
||||
<div>
|
||||
<div className="flex flex-col items-start divide-y divide-slate-800">
|
||||
<div className="p-4">
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Start a brand new Job run with the same Trigger data as this one. This will re-do
|
||||
every Task.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
variant="secondary/medium"
|
||||
type="submit"
|
||||
name={conform.INTENT}
|
||||
value="start"
|
||||
fullWidth
|
||||
LeadingIcon={BoltIcon}
|
||||
className="text-bright"
|
||||
>
|
||||
<BoltIcon className="mr-1 h-3.5 w-3.5 text-bright" />
|
||||
Run again
|
||||
</Button>
|
||||
|
||||
<Paragraph variant="extra-small" className="mt-2">
|
||||
Start a brand new job run with the same Trigger data as this one. This will re-do
|
||||
every task.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{status === "FAILED" && (
|
||||
<div className="pt-4">
|
||||
<div className="p-4">
|
||||
<Paragraph variant="small" className="mb-3">
|
||||
Continue running this Job run from where it left off. This will skip any Task that
|
||||
has already been completed.
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
variant="secondary/medium"
|
||||
type="submit"
|
||||
name={conform.INTENT}
|
||||
value="continue"
|
||||
fullWidth
|
||||
LeadingIcon={ForwardIcon}
|
||||
className="text-bright"
|
||||
>
|
||||
Retry job run
|
||||
<PlayIcon className="mr-1 h-3.5 w-3.5 text-bright" />
|
||||
Retry Job run
|
||||
</Button>
|
||||
|
||||
<Paragraph variant="extra-small" className="mt-2">
|
||||
Continue running this job run from where it left off. This will skip any task that
|
||||
has already been completed.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,19 @@ 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 }) {
|
||||
<Paragraph variant="small">No input</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Output</Header3>
|
||||
{output ? (
|
||||
<ClientOnly fallback={<Spinner />}>
|
||||
{() => <CodeBlock code={output} maxLines={35} />}
|
||||
</ClientOnly>
|
||||
) : (
|
||||
<Paragraph variant="small">No output</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
{!noop && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Output</Header3>
|
||||
{output && !outputIsUndefined ? (
|
||||
<ClientOnly fallback={<Spinner />}>
|
||||
{() => <CodeBlock code={output} maxLines={35} />}
|
||||
</ClientOnly>
|
||||
) : (
|
||||
<Paragraph variant="small">No output</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</RunPanelBody>
|
||||
</RunPanel>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center">
|
||||
<Callout variant="warning" className="w-auto">
|
||||
{title}
|
||||
</Callout>
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<R>(
|
||||
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<R>(
|
||||
|
||||
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({
|
||||
|
||||
@@ -16,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;
|
||||
|
||||
@@ -190,3 +192,5 @@ function logError(error: unknown, request?: Request) {
|
||||
}
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
|
||||
|
||||
@@ -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<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -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<string | null>(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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectJob } from "./useJobs";
|
||||
import { ProjectJob } from "~/presenters/JobListPresenter.server";
|
||||
import { useTextFilter } from "./useTextFilter";
|
||||
import { useToggleFilter } from "./useToggleFilter";
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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<typeof loader>["client"];
|
||||
|
||||
export function useOptionalIntegrationClient(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "routes/_app.orgs.$organizationSlug.projects.$projectParam.integrations_.$clientParam",
|
||||
id: "routes/_app.orgs.$organizationSlug.integrations_.$clientParam",
|
||||
matches,
|
||||
});
|
||||
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { UIMatch, useMatches } from "@remix-run/react";
|
||||
|
||||
export function useIsProjectChildPage(matches?: UIMatch[]) {
|
||||
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");
|
||||
});
|
||||
}
|
||||
@@ -9,17 +9,18 @@ export type MatchedJob = UseDataFunctionReturn<typeof loader>["job"];
|
||||
|
||||
export const jobMatchId =
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam";
|
||||
|
||||
export function useOptionalJob(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
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?: UIMatch[]) {
|
||||
|
||||
@@ -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 { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type ProjectJob = UseDataFunctionReturn<typeof loader>["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?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: jobsMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.projectJobs;
|
||||
}
|
||||
|
||||
export function useJobs(matches?: UIMatch[]) {
|
||||
const jobs = useOptionalJobs(matches);
|
||||
invariant(jobs, "Jobs must be defined");
|
||||
return jobs;
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
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 { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof appLoader>["organizations"][number];
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
|
||||
export const organizationMatchId = "routes/_app.orgs.$organizationSlug";
|
||||
|
||||
export function useOptionalOrganizations(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof appLoader>({
|
||||
id: "routes/_app",
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.organizations;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEventSource } from "remix-utils/sse/react";
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,61 +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<Organization, "slug"> & {
|
||||
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(
|
||||
{
|
||||
@@ -136,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,3 +111,7 @@ export async function createEnvironment(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createShortcode() {
|
||||
return generate({ exactly: 2 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<LoggedI
|
||||
export async function findOrCreateMagicLinkUser(
|
||||
input: FindOrCreateMagicLink
|
||||
): Promise<LoggedInUser> {
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 { workerLogger as logger, trace } from "~/services/logger.server";
|
||||
|
||||
export interface MessageCatalogSchema {
|
||||
[key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
@@ -164,7 +164,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#logDebug("pool:create", { attempts });
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:listen:success", ({ workerPool, client }) => {
|
||||
this.#runner?.events.on("pool:listen:success", async ({ workerPool, client }) => {
|
||||
this.#logDebug("pool:listen:success");
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
spec,
|
||||
});
|
||||
|
||||
const job = await this.#addJob(
|
||||
const { job, durationInMs } = await this.#addJob(
|
||||
identifier as string,
|
||||
payload,
|
||||
spec,
|
||||
@@ -282,6 +282,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
payload,
|
||||
spec,
|
||||
job,
|
||||
durationInMs,
|
||||
});
|
||||
|
||||
return job;
|
||||
@@ -304,6 +305,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
spec: TaskSpec,
|
||||
tx: PrismaClientOrTransaction
|
||||
) {
|
||||
const now = performance.now();
|
||||
|
||||
const results = await tx.$queryRawUnsafe(
|
||||
`SELECT * FROM ${this.graphileWorkerSchema}.add_job(
|
||||
identifier => $1::text,
|
||||
@@ -327,6 +330,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
spec.jobKeyMode || null
|
||||
);
|
||||
|
||||
const durationInMs = performance.now() - now;
|
||||
|
||||
const rows = AddJobResultsSchema.safeParse(results);
|
||||
|
||||
if (!rows.success) {
|
||||
@@ -337,7 +342,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
const job = rows.data[0];
|
||||
|
||||
return job as GraphileJob;
|
||||
return { job: job as GraphileJob, durationInMs: Math.floor(durationInMs) };
|
||||
}
|
||||
|
||||
async #removeJob(jobKey: string, tx: PrismaClientOrTransaction) {
|
||||
@@ -471,7 +476,15 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
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(
|
||||
|
||||
@@ -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<Endpoint, "id" | "slug" | "url" | "indexingHookIdentifier"> & {
|
||||
indexings: Pick<EndpointIndex, "status" | "source" | "updatedAt" | "stats" | "error">[];
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { TriggerHttpEndpoint } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export class HttpEndpointPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
httpEndpointKey,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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,
|
||||
}));
|
||||
|
||||
return {
|
||||
httpEndpoint: {
|
||||
...httpEndpoint,
|
||||
|
||||
httpEndpointEnvironments,
|
||||
},
|
||||
environments: relevantEnvironments,
|
||||
unconfiguredEnvironments: relevantEnvironments.filter(
|
||||
(e) => httpEndpointEnvironments.find((h) => h.environment.type === e.type) === undefined
|
||||
),
|
||||
secret,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<Project, "slug"> & {
|
||||
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
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<JobListPresenter["call"]>>[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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<OrganizationsPresenter["getOrganizations"]>>[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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -156,6 +156,7 @@ export class RunPresenter {
|
||||
completedAt: true,
|
||||
style: true,
|
||||
parentId: true,
|
||||
noop: true,
|
||||
runConnection: {
|
||||
select: {
|
||||
integration: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>,
|
||||
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);
|
||||
|
||||
@@ -66,6 +66,18 @@ export class TestJobPresenter {
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
environment: {
|
||||
OR: [
|
||||
{
|
||||
orgMember: null,
|
||||
},
|
||||
{
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
@@ -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 (
|
||||
<li key={organization.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-slate-800 bg-gradient-to-bl from-indigo-900/70 via-indigo-500/10 to-slate-900 to-70% p-2"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={organizationPath(organization)}
|
||||
className="group flex items-center gap-y-4 rounded-md border-b border-slate-800 px-2 pb-4 pt-2"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-x-2">
|
||||
<NamedIcon name="organization" className="h-10 w-10 flex-none" aria-hidden="true" />
|
||||
<div className="flex-1">
|
||||
<Header2 className="">{organization.title}</Header2>
|
||||
<Paragraph variant="extra-small">
|
||||
{simplur`${organization._count.members} team member[|s]`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className="h-5 w-5 text-dimmed/0 transition group-hover:text-dimmed/100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
<div className="pt-4">
|
||||
<Paragraph className="mb-2 px-2" variant="extra-small/bright/caps">
|
||||
Projects
|
||||
</Paragraph>
|
||||
<div>
|
||||
{organization.projects.map((project) => (
|
||||
<LinkButton
|
||||
variant="menu-item"
|
||||
to={projectPath(organization, project)}
|
||||
key={project.id}
|
||||
LeadingIcon="folder"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<span className="flex grow items-center justify-between pl-1">
|
||||
<span className="grow text-left">{project.name}</span>
|
||||
<Badge>{simplur`${project._count.jobs} job[|s]`}</Badge>
|
||||
</span>
|
||||
</LinkButton>
|
||||
))}
|
||||
<LinkButton
|
||||
to={newProjectPath(organization)}
|
||||
variant="menu-item"
|
||||
LeadingIcon="plus"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
New Project
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { LoaderFunctionArgs, 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 }: 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());
|
||||
}
|
||||
|
||||
//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 (
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,_minmax(350px,_auto))]">
|
||||
<>
|
||||
{organizations &&
|
||||
organizations.map((organization) => (
|
||||
<OrganizationGridItem key={organization.id} organization={organization} />
|
||||
))}
|
||||
</>
|
||||
<li>
|
||||
<Link
|
||||
to={newOrganizationPath()}
|
||||
className="group grid h-full grow place-items-center rounded-md border border-slate-800 p-2 shadow-sm shadow-transparent transition duration-300 hover:border-indigo-900 hover:shadow-glow-secondary"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||
<NamedIcon
|
||||
name="plus"
|
||||
className="h-10 w-10 text-dimmed transition duration-300 group-hover:text-green-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Paragraph variant="small" className="transition duration-300 group-hover:text-bright">
|
||||
New Organization
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageContainer>
|
||||
<PageHeader hideBorder>
|
||||
<PageTitleRow>
|
||||
<PageTitle title="Organizations and Account" />
|
||||
<PageButtons>
|
||||
<LinkButton
|
||||
to={logoutPath()}
|
||||
variant="tertiary/small"
|
||||
TrailingIcon={ArrowRightOnRectangleIcon}
|
||||
>
|
||||
Logout
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageDescription>Create new Organizations and manage your account.</PageDescription>
|
||||
<PageTabs
|
||||
tabs={[
|
||||
{ label: "Organizations", to: organizationsPath() },
|
||||
{ label: "Account", to: accountPath() },
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<PageHeader hideBorder>
|
||||
<PageTitleRow>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: organizationsPath(),
|
||||
text: "Organizations and Account",
|
||||
}}
|
||||
title={organization.title}
|
||||
/>
|
||||
<PageButtons>
|
||||
<LinkButton
|
||||
to={newProjectPath(organization)}
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Create a new project
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageDescription>Manage your projects and team.</PageDescription>
|
||||
<PageTabs
|
||||
tabs={[
|
||||
{ label: "Projects", to: organizationPath(organization) },
|
||||
{ label: "Team", to: organizationTeamPath(organization) },
|
||||
{
|
||||
label: "Usage & billing",
|
||||
to: organizationBillingPath(organization),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
@@ -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) => <BreadcrumbLink to={match.pathname} title="Projects" />,
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<OrgAdminHeader />
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${organization.title} projects`} />
|
||||
<PageButtons>
|
||||
<LinkButton
|
||||
to={newProjectPath(organization)}
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Create a new project
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{organization.projects.length > 0 ? (
|
||||
@@ -22,13 +46,13 @@ export default function Page() {
|
||||
return (
|
||||
<li key={project.id}>
|
||||
<Link
|
||||
className="flex gap-4 rounded-md border border-slate-850 bg-slate-900 bg-gradient-to-b from-indigo-900/60 to-slate-900 to-70% p-4"
|
||||
className="flex gap-4 rounded-md border border-ui-border-dimmed p-4 transition hover:bg-slate-900 "
|
||||
to={projectPath(organization, project)}
|
||||
>
|
||||
<NamedIcon name="folder" className="h-10 w-10 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<Header3>{project.name}</Header3>
|
||||
<Paragraph variant="small">{simplur`${project._count.jobs} job[|s]`}</Paragraph>
|
||||
<Paragraph variant="small">{simplur`${project.jobCount} Job[|s]`}</Paragraph>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -11,13 +11,26 @@ import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { OrganizationParamsSchema, jobPath, organizationTeamPath } from "~/utils/pathBuilder";
|
||||
import { OrgAdminHeader } from "../_app.orgs.$organizationSlug._index/OrgAdminHeader";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
jobPath,
|
||||
newProjectPath,
|
||||
organizationTeamPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Link } from "@remix-run/react/dist/components";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
PageHeader,
|
||||
PageTitleRow,
|
||||
PageTitle,
|
||||
PageButtons,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -47,13 +60,21 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => <BreadcrumbLink to={match.pathname} title="Usage & Billing" />,
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const loaderData = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<OrgAdminHeader />
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title="Usage & Billing" />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded border border-border p-6">
|
||||
@@ -151,7 +172,7 @@ export default function Page() {
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6">
|
||||
<div className="w-1/2 overflow-y-auto rounded border border-border px-3 py-6 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mb-2 flex items-baseline justify-between border-b border-border px-3 pb-4">
|
||||
<Header2 className="">Jobs</Header2>
|
||||
<Header2 className="">Runs</Header2>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user