Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| daae6df428 | |||
| fc31c6c07a | |||
| 6d4676f204 | |||
| 0a51ef3a06 | |||
| 772f1b41a0 | |||
| d42ba80108 | |||
| 6a3c563f14 | |||
| c04cdfde8c | |||
| 750c1ff1e3 | |||
| 0f9f010206 | |||
| 11f8ff1bb4 | |||
| 85b3352764 | |||
| 9fb8dceef4 | |||
| 0c14e4cdfe | |||
| 2099c6308e | |||
| 08e6cad28a | |||
| f3efcc0c28 | |||
| 37ef335b66 | |||
| 33d555d00e | |||
| 17f6f29d05 | |||
| 08f7c639ef | |||
| 1567239718 | |||
| de652c1dfb | |||
| 1f3733b70f | |||
| b5aea6c534 | |||
| 0769dc4315 | |||
| 5d00fc7cdb | |||
| 00b0c3e02e | |||
| 7e3a82ef47 | |||
| 5dda6cd16c | |||
| 76b7fb2337 | |||
| 68cbfd8d23 | |||
| 41a49f6bb2 | |||
| 2f755158b4 | |||
| 419b93809d | |||
| bd4bc51daa | |||
| ff540c9e4a | |||
| 3f217ff3e0 | |||
| 9cb39bf7d7 | |||
| ca05d5f603 | |||
| 4dc46cbbe4 | |||
| 1dcd87a2aa | |||
| c4cb98af5c | |||
| 6ebd435e81 | |||
| caf203c084 | |||
| dbc2e3f713 | |||
| a5b49cb61f | |||
| 096151c014 | |||
| 5d28f1a3d6 | |||
| 56ae0ed0d3 | |||
| a2f5dc8bc6 | |||
| d56b2eded5 | |||
| 0ae9adafbf | |||
| 03403ad9cb | |||
| 067e19fec9 | |||
| 2cce68b5f7 | |||
| e3c3aa91e1 | |||
| 5c0ca83852 | |||
| 2b24104c97 | |||
| 5977a5aa51 | |||
| 131d0dbea4 |
@@ -12,6 +12,11 @@ APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis is used for concurrency control
|
||||
# REDIS_HOST="localhost"
|
||||
# REDIS_PORT="6379"
|
||||
# REDIS_TLS_DISABLED="true"
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -7,6 +7,7 @@ jobs:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
name: 🤖 PR Checks
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "**.mdx"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
name: "🐳 Publish Docker"
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
short_sha: ${{ steps.get_commit.outputs.sha_short }}
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 🆚 Get the version
|
||||
id: get_version
|
||||
run: |
|
||||
IMAGE_TAG="${GITHUB_REF#refs/tags/}"
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
if [[ $IMAGE_TAG == v.docker.* ]]; then
|
||||
ORIGINAL_VERSION="${IMAGE_TAG#v.docker.}"
|
||||
IMAGE_TAG="v${ORIGINAL_VERSION}"
|
||||
elif [[ $IMAGE_TAG == build-* ]]; then
|
||||
IMAGE_TAG="${IMAGE_TAG#build-}"
|
||||
fi
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
# Handle main branch specifically
|
||||
IMAGE_TAG="main"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
else
|
||||
echo "Invalid reference: ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::set-output name=version::${IMAGE_TAG}"
|
||||
- name: 🔢 Get the commit hash
|
||||
id: get_commit
|
||||
run: |
|
||||
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
|
||||
|
||||
- name: 🐳 Build Docker Image
|
||||
run: |
|
||||
docker build -t release_build_image -f ./docker/Dockerfile .
|
||||
|
||||
- name: 🐙 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 🐙 Push to GitHub Container Registry
|
||||
run: |
|
||||
docker tag release_build_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: trigger.dev
|
||||
IMAGE_TAG: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
- name: 🐙 Push 'latest' to GitHub Container Registry
|
||||
if: startsWith(github.ref, 'refs/tags/v.docker')
|
||||
run: |
|
||||
docker tag release_build_image $REGISTRY/$REPOSITORY:latest
|
||||
docker push $REGISTRY/$REPOSITORY:latest
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: trigger.dev
|
||||
@@ -4,9 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- improvements/*
|
||||
tags:
|
||||
- "v.docker.*"
|
||||
- "build-*"
|
||||
paths:
|
||||
- ".github/workflows/publish.yml"
|
||||
- "packages/**"
|
||||
@@ -51,71 +51,5 @@ jobs:
|
||||
|
||||
publish:
|
||||
needs: [typecheck, units, e2e]
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
short_sha: ${{ steps.get_commit.outputs.sha_short }}
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: 🆚 Get the version
|
||||
id: get_version
|
||||
run: |
|
||||
IMAGE_TAG="${GITHUB_REF#refs/tags/}"
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
if [[ $IMAGE_TAG == v.docker.* ]]; then
|
||||
ORIGINAL_VERSION="${IMAGE_TAG#v.docker.}"
|
||||
IMAGE_TAG="v${ORIGINAL_VERSION}"
|
||||
fi
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
elif [[ $GITHUB_REF == refs/heads/improvements/* ]]; then
|
||||
ORIGINAL_VERSION="${GITHUB_REF#refs/heads/improvements/}"
|
||||
IMAGE_TAG="${ORIGINAL_VERSION}.rc"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
elif [[ $GITHUB_REF == refs/heads/* ]]; then
|
||||
IMAGE_TAG="${GITHUB_REF#refs/heads/}"
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}"
|
||||
else
|
||||
echo "Invalid reference: ${GITHUB_REF}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::set-output name=version::${IMAGE_TAG}"
|
||||
- name: 🔢 Get the commit hash
|
||||
id: get_commit
|
||||
run: |
|
||||
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
|
||||
|
||||
- name: 🐳 Build Docker Image
|
||||
run: |
|
||||
docker build -t release_build_image -f ./docker/Dockerfile .
|
||||
|
||||
- name: 🐙 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 🐙 Push to GitHub Container Registry
|
||||
run: |
|
||||
docker tag release_build_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: trigger.dev
|
||||
IMAGE_TAG: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
- name: 🐙 Push 'latest' to GitHub Container Registry
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
docker tag release_build_image $REGISTRY/$REPOSITORY:latest
|
||||
docker push $REGISTRY/$REPOSITORY:latest
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: trigger.dev
|
||||
uses: ./.github/workflows/publish-docker.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -22,6 +22,16 @@ jobs:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: ⎔ Setup Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
|
||||
- name: ⎔ Setup bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: "1.0.15"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"deno.enablePaths": ["references/deno-reference"],
|
||||
"deno.enablePaths": ["references/deno-reference", "runtime_tests/tests/deno"],
|
||||
"debug.toolBarLocation": "commandCenter"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# proxy
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.5
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.4
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.3
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.2
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f3efcc0c]
|
||||
- @trigger.dev/core@2.3.1
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [17f6f29d]
|
||||
- @trigger.dev/core@2.3.0
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.2.11
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.2.10
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6ebd435e]
|
||||
- @trigger.dev/core@2.2.9
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [067e19fe]
|
||||
- @trigger.dev/core@2.2.8
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
|
||||
@@ -16,19 +16,23 @@ export type JobEnvironment = {
|
||||
lastRun?: Date;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
concurrencyLimit?: number | null;
|
||||
concurrencyLimitGroup?: { name: string; concurrencyLimit: number } | null;
|
||||
};
|
||||
|
||||
type JobStatusTableProps = {
|
||||
environments: JobEnvironment[];
|
||||
displayStyle?: "short" | "long";
|
||||
};
|
||||
|
||||
export function JobStatusTable({ environments }: JobStatusTableProps) {
|
||||
export function JobStatusTable({ environments, displayStyle = "short" }: JobStatusTableProps) {
|
||||
return (
|
||||
<Table fullWidth>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Last Run</TableHeaderCell>
|
||||
{displayStyle === "long" && <TableHeaderCell>Concurrency</TableHeaderCell>}
|
||||
<TableHeaderCell alignment="right">Version</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Status</TableHeaderCell>
|
||||
</TableRow>
|
||||
@@ -42,6 +46,23 @@ export function JobStatusTable({ environments }: JobStatusTableProps) {
|
||||
<TableCell>
|
||||
{environment.lastRun ? <DateTime date={environment.lastRun} /> : "Never Run"}
|
||||
</TableCell>
|
||||
{displayStyle === "long" && (
|
||||
<TableCell>
|
||||
{environment.concurrencyLimitGroup ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{environment.concurrencyLimitGroup.name}</span>
|
||||
<span className="text-gray-400">
|
||||
({environment.concurrencyLimitGroup.concurrencyLimit})
|
||||
</span>
|
||||
</span>
|
||||
) : typeof environment.concurrencyLimit === "number" ? (
|
||||
<span className="text-gray-400">{environment.concurrencyLimit}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Not specified</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell alignment="right">{environment.version}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<ActiveBadge active={environment.enabled} />
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BoltIcon,
|
||||
CloudIcon,
|
||||
CodeBracketIcon,
|
||||
CodeBracketSquareIcon,
|
||||
HeartIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LogoType } from "./LogoType";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Icon } from "./primitives/Icon";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { LoginTooltip } from "./primitives/Tooltip";
|
||||
|
||||
interface QuoteType {
|
||||
quote: string;
|
||||
person: string;
|
||||
}
|
||||
|
||||
const quotes: QuoteType[] = [
|
||||
{
|
||||
quote: "Trigger.dev is redefining background jobs for modern developers.",
|
||||
person: "Paul Copplestone, Supabase",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Trigger.dev is a great way to automate email campaigns with Resend, and we've heard nothing but good things from our mutual customers.",
|
||||
person: "Zeno Rocha, Resend",
|
||||
},
|
||||
{
|
||||
quote: "We love Trigger.dev and it’s had a big impact in dev iteration velocity already.",
|
||||
person: "André Neves, ZBD",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"We’ve been looking for a product like Trigger.dev for a really long time - automation that's simple and developer-focused.",
|
||||
person: "Han Wang, Mintlify",
|
||||
},
|
||||
];
|
||||
|
||||
const layout = "group grid place-items-center text-center overflow-hidden";
|
||||
const gridCell = "hover:bg-midnight-850 rounded-lg transition bg-midnight-850/40";
|
||||
const opacity = "opacity-10 group-hover:opacity-100 transition group-hover:scale-105";
|
||||
const logos = "h-[60%] w-[60%] transition grayscale group-hover:grayscale-0";
|
||||
const features = "h-[60%] w-[60%] text-gray-500 grayscale transition group-hover:grayscale-0";
|
||||
const wide = "col-span-2";
|
||||
const wider = "col-span-3 row-span-2";
|
||||
const mediumSquare = "col-span-2 row-span-2";
|
||||
const hidden = "hidden xl:grid";
|
||||
|
||||
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * quotes.length);
|
||||
setRandomQuote(quotes[randomIndex]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="grid h-full grid-cols-12">
|
||||
<div className="border-midnight-750 z-10 col-span-12 border-r bg-midnight-850 md:col-span-6">
|
||||
<div className="flex h-full flex-col items-center justify-between p-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<a href="https://trigger.dev">
|
||||
<LogoType className="w-36" />
|
||||
</a>
|
||||
<LinkButton
|
||||
to="https://trigger.dev/docs"
|
||||
variant={"secondary/small"}
|
||||
LeadingIcon="docs"
|
||||
>
|
||||
Documentation
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="flex h-full max-w-sm items-center justify-center">{children}</div>
|
||||
<Paragraph variant="extra-small" className="text-center">
|
||||
Having login issues? <TextLink href="mailto:help@trigger.dev">Email us</TextLink> or{" "}
|
||||
<TextLink href="https://trigger.dev/discord">ask us in Discord</TextLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden grid-cols-3 grid-rows-6 gap-4 p-4 md:col-span-6 md:grid xl:grid-cols-5">
|
||||
<LoginTooltip side="bottom" content={<ServerlessTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, mediumSquare)}>
|
||||
<ServerStackIcon className={cn(opacity, features, "group-hover:text-green-500")} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="bottom" content={<SlackTooltipContent />}>
|
||||
<div className={cn(layout, gridCell)}>
|
||||
<Icon icon="slack" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="left" content={<TriggerTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, mediumSquare, hidden)}>
|
||||
<BoltIcon className={cn(opacity, features, "group-hover:text-yellow-500")} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="bottom" content={<StripeTooltipContent />} className="max-w-[15rem]">
|
||||
<div className={cn("", layout, gridCell)}>
|
||||
<Icon icon="stripe" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="top" content={<QuoteTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, wider)}>
|
||||
<div className="px-4">
|
||||
<Header3 className="relative text-2xl font-normal leading-8 text-gray-600 transition before:relative before:right-1 before:top-0 before:text-4xl before:text-slate-600 before:opacity-20 before:content-['❝'] group-hover:text-slate-500 group-hover:before:opacity-30 lg-height:text-xl md-height:text-lg">
|
||||
{randomQuote?.quote}
|
||||
</Header3>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="mt-4 text-gray-700 transition group-hover:text-slate-600"
|
||||
>
|
||||
{randomQuote?.person}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="left" content={<OpenaiTooltipContent />}>
|
||||
<div className={cn("", layout, gridCell, hidden)}>
|
||||
<Icon icon="openai" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="left" content={<SendgridTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, hidden)}>
|
||||
<Icon icon="sendgrid" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="left" content={<ReactHooksTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, mediumSquare, hidden)}>
|
||||
<Icon icon="react" className={cn(opacity, features, "group-hover:text-green-500")} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="right" content={<AirtableTooltipContent />}>
|
||||
<div className={cn("", layout, gridCell)}>
|
||||
<Icon icon="airtable" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="top" content={<InYourCodebaseTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, mediumSquare)}>
|
||||
<CodeBracketSquareIcon className={cn(opacity, features, "group-hover:text-rose-500")} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="right" content={<SupabaseTooltipContent />}>
|
||||
<div className={cn(layout, gridCell)}>
|
||||
<Icon icon="supabase" className={cn(logos, opacity)} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
<LoginTooltip side="left" content={<CloudTooltipContent />}>
|
||||
<div className={cn(layout, gridCell, wide, hidden)}>
|
||||
<CloudIcon className={cn(opacity, features, "h-20 w-20 group-hover:text-blue-600")} />
|
||||
</div>
|
||||
</LoginTooltip>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="slack" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Slack Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">Post messages to your team when your Job is triggered.</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StripeTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="stripe" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Stripe Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">Trigger payments, emails, subscription upgrades…</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SupabaseTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="supabase" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Supabase Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">React to changes in your database.</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SendgridTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="sendgrid" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
SendGrid Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Create a drip campaign, trigger an onboarding sequence and more…
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AirtableTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="airtable" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Airtable Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Update your Airtable records when you make a Stripe sale, receive a new Typeform response
|
||||
and more…
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenaiTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="openai" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
OpenAI Integration
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">Generate text, images, code and more with OpenAI's API.</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<BoltIcon className="h-5 w-5 text-yellow-500" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Triggering your Job
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Trigger your Jobs with a webhook, on a recurring schedule, or from your own custom events.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<HeartIcon className="h-5 w-5 text-rose-500" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Loved by developers
|
||||
</Paragraph>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InYourCodebaseTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<CodeBracketSquareIcon className="h-5 w-5 text-rose-500" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
In your codebase
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Create background jobs where they belong: in your codebase.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<CloudIcon className="h-5 w-5 text-blue-600" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Zero infrastructure
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Use our SDK to write Jobs in your codebase and deploy as you normally do.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerlessTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<ServerStackIcon className="h-5 w-5 text-green-500" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Full serverless support
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Run long-running background jobs without worrying about timeouts.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReactHooksTooltipContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-x-1.5">
|
||||
<Icon icon="react" className="h-5 w-5" />
|
||||
<Paragraph variant="base/bright" className="font-semibold">
|
||||
Show Job progress in your UI
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="base">
|
||||
Use our React hooks to display a real-time status to your users.
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
export function LogoType({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 751 130" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path
|
||||
d="M195.022 16.2676H135.445H137.799V32.5096H157.858V102.4H174.84V32.5096H195.022V16.2676Z"
|
||||
fill="url(#paint0_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M211.265 51.4587V40.8767H195.391V102.4H211.265V72.9917C211.265 60.0719 221.725 56.3805 229.97 57.3648V39.6463C222.218 39.6463 214.465 43.0916 211.265 51.4587Z"
|
||||
fill="url(#paint1_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M246.954 33.494C252.368 33.494 256.799 29.0644 256.799 23.7734C256.799 18.4824 252.368 13.9297 246.954 13.9297C241.662 13.9297 237.232 18.4824 237.232 23.7734C237.232 29.0644 241.662 33.494 246.954 33.494ZM239.078 102.4H254.953V40.8767H239.078V102.4Z"
|
||||
fill="url(#paint2_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M315.253 40.8768V48.5056C310.946 42.7224 304.301 39.1542 295.563 39.1542C278.089 39.1542 264.921 53.4275 264.921 70.6539C264.921 88.0033 278.089 102.154 295.563 102.154C304.301 102.154 310.946 98.5853 315.253 92.8021V99.4466C315.253 109.167 309.1 114.581 299.132 114.581C289.656 114.581 285.596 110.767 283.011 105.968L269.475 113.72C274.889 123.687 285.472 128.731 298.64 128.731C314.884 128.731 330.758 119.626 330.758 99.4466V40.8768H315.253ZM298.025 87.5112C288.057 87.5112 280.796 80.4975 280.796 70.6539C280.796 60.9332 288.057 53.9196 298.025 53.9196C307.992 53.9196 315.253 60.9332 315.253 70.6539C315.253 80.4975 307.992 87.5112 298.025 87.5112Z"
|
||||
fill="url(#paint3_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M390.936 40.8768V48.5056C386.629 42.7224 379.983 39.1542 371.246 39.1542C353.772 39.1542 340.604 53.4275 340.604 70.6539C340.604 88.0033 353.772 102.154 371.246 102.154C379.983 102.154 386.629 98.5853 390.936 92.8021V99.4466C390.936 109.167 384.783 114.581 374.815 114.581C365.339 114.581 361.278 110.767 358.694 105.968L345.157 113.72C350.572 123.687 361.155 128.731 374.322 128.731C390.566 128.731 406.441 119.626 406.441 99.4466V40.8768H390.936ZM373.707 87.5112C363.739 87.5112 356.479 80.4975 356.479 70.6539C356.479 60.9332 363.739 53.9196 373.707 53.9196C383.675 53.9196 390.936 60.9332 390.936 70.6539C390.936 80.4975 383.675 87.5112 373.707 87.5112Z"
|
||||
fill="url(#paint4_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M432.9 78.1597H479.293C479.663 76.0679 479.909 73.9761 479.909 71.6383C479.909 53.5505 466.987 39.1542 448.775 39.1542C429.454 39.1542 416.287 53.3044 416.287 71.6383C416.287 89.9721 429.331 104.122 450.005 104.122C461.819 104.122 471.048 99.3236 476.832 90.9564L464.034 83.5737C461.327 87.142 456.404 89.726 450.251 89.726C441.883 89.726 435.115 86.2807 432.9 78.1597ZM432.654 65.8551C434.5 57.9802 440.284 53.4274 448.775 53.4274C455.42 53.4274 462.065 56.9958 464.034 65.8551H432.654Z"
|
||||
fill="url(#paint5_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M505.199 51.4587V40.8767H489.324V102.4H505.199V72.9917C505.199 60.0719 515.659 56.3805 523.904 57.3648V39.6463C516.151 39.6463 508.398 43.0916 505.199 51.4587Z"
|
||||
fill="url(#paint6_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M529.934 103.999C535.717 103.999 540.394 99.3235 540.394 93.5404C540.394 87.7572 535.717 83.0815 529.934 83.0815C524.15 83.0815 519.473 87.7572 519.473 93.5404C519.473 99.3235 524.15 103.999 529.934 103.999Z"
|
||||
fill="url(#paint7_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M596.632 16.2676V48.1364C592.202 42.4763 585.679 39.1541 576.696 39.1541C560.206 39.1541 546.67 53.3044 546.67 71.6382C546.67 89.972 560.206 104.122 576.696 104.122C585.679 104.122 592.202 100.8 596.632 95.1399V102.4H612.506V16.2676L596.632 16.2676ZM579.65 88.9876C569.805 88.9876 562.544 81.9741 562.544 71.6382C562.544 61.3024 569.805 54.2887 579.65 54.2887C589.371 54.2887 596.632 61.3024 596.632 71.6382C596.632 81.9741 589.371 88.9876 579.65 88.9876Z"
|
||||
fill="url(#paint8_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M637.98 78.1597H684.373C684.742 76.0679 684.989 73.9761 684.989 71.6383C684.989 53.5505 672.067 39.1542 653.855 39.1542C634.534 39.1542 621.367 53.3044 621.367 71.6383C621.367 89.9721 634.411 104.122 655.085 104.122C666.899 104.122 676.128 99.3236 681.912 90.9564L669.114 83.5737C666.407 87.142 661.484 89.726 655.331 89.726C646.963 89.726 640.195 86.2807 637.98 78.1597ZM637.734 65.8551C639.58 57.9802 645.363 53.4274 653.855 53.4274C660.5 53.4274 667.145 56.9958 669.114 65.8551H637.734Z"
|
||||
fill="url(#paint9_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M732.859 40.8768L717.846 83.9428L702.955 40.8768H685.481L708.862 102.4H726.952L750.333 40.8768H732.859Z"
|
||||
fill="url(#paint10_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M35.664 42.3949L59.4114 1.26865L118.264 103.194H0.558823L24.3062 62.0665L41.1046 71.7643L34.157 83.7971H84.6657L59.4114 40.0612L52.4637 52.094L35.664 42.3949Z"
|
||||
fill="url(#paint11_linear_228_1439)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint8_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint9_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint10_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint11_linear_228_1439"
|
||||
x1="95.8593"
|
||||
y1="103.194"
|
||||
x2="94.7607"
|
||||
y2="31.2381"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -78,10 +78,16 @@ export function PageBodyPadding({ children }: { children: React.ReactNode }) {
|
||||
return <div className="p-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function MainCenteredContainer({ children }: { children: React.ReactNode }) {
|
||||
export function MainCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<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 className={cn("mx-auto mt-[25vh] max-w-xs overflow-y-auto", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
projectEnvironmentsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -120,6 +121,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
to={projectRunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
|
||||
@@ -106,6 +106,33 @@ const variant = {
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"primary/extra-large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80",
|
||||
button:
|
||||
"h-12 px-2 text-md font-medium bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
|
||||
icon: "h-5",
|
||||
iconSpacing: undefined,
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"secondary/extra-large": {
|
||||
textColor: "text-dimmed",
|
||||
button:
|
||||
"h-12 px-2 text-md 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: undefined,
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"danger/extra-large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50",
|
||||
button:
|
||||
"h-12 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: undefined,
|
||||
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 px-1",
|
||||
button:
|
||||
@@ -277,8 +304,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
}
|
||||
);
|
||||
|
||||
type LinkPropsType = Pick<LinkProps, "to" | "target"> & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
type LinkPropsType = Pick<LinkProps, "to" | "target" | "onClick"> &
|
||||
React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({ to, onClick, ...props }: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
if (props.shortcut) {
|
||||
useShortcutKeys({
|
||||
@@ -297,6 +325,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ButtonContent {...props} />
|
||||
</ExtLink>
|
||||
@@ -307,6 +336,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ButtonContent {...props} />
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
const headerVariants = {
|
||||
header1: {
|
||||
text: "font-sans text-base md:text-lg lg:text-xl leading-5 md:leading-6 lg:leading-7 font-semibold",
|
||||
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-semibold",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
header2: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NamedIcon } from "./NamedIcon";
|
||||
const variants = {
|
||||
large: {
|
||||
input:
|
||||
"px-3 flex h-10 w-full text-bright rounded-md border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"px-3 flex h-10 w-full text-bright rounded-[3px] border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
|
||||
iconSize: "h-4 w-4 ml-3",
|
||||
iconOffset: "pl-[34px]",
|
||||
@@ -33,7 +33,7 @@ const variants = {
|
||||
},
|
||||
tertiary: {
|
||||
input:
|
||||
"px-1 flex h-6 w-full text-bright rounded bg-transparent border border-transparent transition hover:border-slate-800 hover:bg-slate-850 focus:border-slate-800 focus:bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"px-1 flex h-6 w-full text-bright rounded bg-transparent border border-transparent hover:border-slate-800 hover:bg-slate-850 focus:border-slate-800 focus:bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
|
||||
iconSize: "h-3 w-3 ml-1.5",
|
||||
iconOffset: "pl-[21px]",
|
||||
|
||||
@@ -100,7 +100,7 @@ export function PageInfoProperty({
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
value: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
to?: string;
|
||||
}) {
|
||||
if (to === undefined) {
|
||||
@@ -121,17 +121,18 @@ function PageInfoPropertyContent({
|
||||
}: {
|
||||
icon?: string | React.ReactNode;
|
||||
label?: string;
|
||||
value: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{icon && typeof icon === "string" ? <NamedIcon name={icon} className="h-4 w-4" /> : icon}
|
||||
{label && (
|
||||
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
|
||||
{label}:
|
||||
{label}
|
||||
{value && ":"}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Paragraph variant="small">{value}</Paragraph>
|
||||
{value && <Paragraph variant="small">{value}</Paragraph>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ export type TabsProps = {
|
||||
to: string;
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-ui-border`, className)}>
|
||||
{tabs.map((tab, index) => (
|
||||
@@ -26,7 +27,7 @@ export function Tabs({ tabs, className }: TabsProps) {
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId="underline" className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-slate-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
|
||||
@@ -61,4 +61,34 @@ function SimpleTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginTooltip({
|
||||
children,
|
||||
side,
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
side: "top" | "bottom" | "left" | "right";
|
||||
content: React.ReactNode | string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={2500} disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className={cn(
|
||||
"max-w-xs border-slate-800 bg-slate-900 px-5 py-4 backdrop-blur-md",
|
||||
className
|
||||
)}
|
||||
side={side}
|
||||
sideOffset={14}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow, SimpleTooltip };
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
} from "@remix-run/react";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { useMemo } from "react";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import type { RunBasicStatus } from "~/models/jobRun.server";
|
||||
import { ViewRun } from "~/presenters/RunPresenter.server";
|
||||
import { cancelSchema } from "~/routes/resources.runs.$runId.cancel";
|
||||
import { schema } from "~/routes/resources.runs.$runId.rerun";
|
||||
import { formatDuration } from "~/utils";
|
||||
import { formatDuration, formatDurationMilliseconds } from "~/utils";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { runCompletedPath, runTaskPath, runTriggerPath } from "~/utils/pathBuilder";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
@@ -38,14 +39,7 @@ import {
|
||||
} from "../primitives/PageHeader";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import {
|
||||
RunBasicStatus,
|
||||
RunStatusIcon,
|
||||
RunStatusLabel,
|
||||
hasFinished,
|
||||
runBasicStatus,
|
||||
runStatusTitle,
|
||||
} from "../runs/RunStatuses";
|
||||
import { RunStatusIcon, RunStatusLabel, runStatusTitle } from "../runs/RunStatuses";
|
||||
import {
|
||||
RunPanel,
|
||||
RunPanelBody,
|
||||
@@ -95,8 +89,6 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
}
|
||||
}, [pathName]);
|
||||
|
||||
const basicStatus = runBasicStatus(run.status);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
@@ -106,7 +98,9 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
to: paths.back,
|
||||
text: "Runs",
|
||||
}}
|
||||
title={`Run #${run.number}`}
|
||||
title={
|
||||
typeof run.number === "number" ? `Run #${run.number}` : `Run ${run.id.slice(0, 8)}`
|
||||
}
|
||||
/>
|
||||
<PageButtons>
|
||||
{run.isTest && (
|
||||
@@ -115,15 +109,15 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
Test run
|
||||
</span>
|
||||
)}
|
||||
{showRerun && hasFinished(run.status) && (
|
||||
{showRerun && run.isFinished && (
|
||||
<RerunPopover
|
||||
runId={run.id}
|
||||
runsPath={paths.runsPath}
|
||||
environmentType={run.environment.type}
|
||||
status={basicStatus}
|
||||
status={run.basicStatus}
|
||||
/>
|
||||
)}
|
||||
{!hasFinished(run.status) && <CancelRun runId={run.id} />}
|
||||
{!run.isFinished && <CancelRun runId={run.id} />}
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageInfoRow>
|
||||
@@ -146,7 +140,17 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
<PageInfoProperty
|
||||
icon={"clock"}
|
||||
label={"Duration"}
|
||||
value={formatDuration(run.startedAt, run.completedAt)}
|
||||
value={formatDuration(run.startedAt, run.completedAt, { style: "short" })}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon={"hourglass"}
|
||||
label={"Execution Time"}
|
||||
value={formatDurationMilliseconds(run.executionDuration, { style: "short" })}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon={"list-numbers"}
|
||||
label={"Execution Count"}
|
||||
value={<>{run.executionCount}</>}
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
<PageInfoGroup alignment="right">
|
||||
@@ -211,10 +215,10 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<BlankTasks status={run.status} basicStatus={basicStatus} />
|
||||
<BlankTasks status={run.basicStatus} />
|
||||
)}
|
||||
</div>
|
||||
{(basicStatus === "COMPLETED" || basicStatus === "FAILED") && (
|
||||
{(run.basicStatus === "COMPLETED" || run.basicStatus === "FAILED") && (
|
||||
<div>
|
||||
<Header2 className={cn("mb-2")}>Run Summary</Header2>
|
||||
<RunPanel
|
||||
@@ -285,14 +289,8 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
);
|
||||
}
|
||||
|
||||
function BlankTasks({
|
||||
status,
|
||||
basicStatus,
|
||||
}: {
|
||||
status: JobRunStatus;
|
||||
basicStatus: RunBasicStatus;
|
||||
}) {
|
||||
switch (basicStatus) {
|
||||
function BlankTasks({ status }: { status: RunBasicStatus }) {
|
||||
switch (status) {
|
||||
default:
|
||||
case "COMPLETED":
|
||||
return <Paragraph variant="small">There were no tasks for this run.</Paragraph>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PauseCircleIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
@@ -10,18 +11,6 @@ import type { JobRunStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
export function hasFinished(status: JobRunStatus): boolean {
|
||||
return (
|
||||
status === "SUCCESS" ||
|
||||
status === "FAILURE" ||
|
||||
status === "ABORTED" ||
|
||||
status === "TIMED_OUT" ||
|
||||
status === "CANCELED" ||
|
||||
status === "UNRESOLVED_AUTH" ||
|
||||
status === "INVALID_PAYLOAD"
|
||||
);
|
||||
}
|
||||
|
||||
export function RunStatus({ status }: { status: JobRunStatus }) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -40,49 +29,26 @@ export function RunStatusIcon({ status, className }: { status: JobRunStatus; cla
|
||||
case "SUCCESS":
|
||||
return <CheckCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "PENDING":
|
||||
case "WAITING_TO_CONTINUE":
|
||||
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "QUEUED":
|
||||
return <ClockIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "WAITING_TO_EXECUTE":
|
||||
return <PauseCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "PREPROCESSING":
|
||||
case "STARTED":
|
||||
case "EXECUTING":
|
||||
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "FAILURE":
|
||||
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "TIMED_OUT":
|
||||
return <ExclamationTriangleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "UNRESOLVED_AUTH":
|
||||
case "FAILURE":
|
||||
case "ABORTED":
|
||||
case "INVALID_PAYLOAD":
|
||||
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "WAITING_ON_CONNECTIONS":
|
||||
return <WrenchIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "PREPROCESSING":
|
||||
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "CANCELED":
|
||||
return <NoSymbolIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
}
|
||||
}
|
||||
|
||||
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
|
||||
|
||||
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
|
||||
switch (status) {
|
||||
case "WAITING_ON_CONNECTIONS":
|
||||
case "QUEUED":
|
||||
case "PREPROCESSING":
|
||||
case "PENDING":
|
||||
return "PENDING";
|
||||
case "STARTED":
|
||||
return "RUNNING";
|
||||
case "FAILURE":
|
||||
case "TIMED_OUT":
|
||||
case "UNRESOLVED_AUTH":
|
||||
case "CANCELED":
|
||||
case "ABORTED":
|
||||
case "INVALID_PAYLOAD":
|
||||
return "FAILED";
|
||||
case "SUCCESS":
|
||||
return "COMPLETED";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
@@ -99,7 +65,12 @@ export function runStatusTitle(status: JobRunStatus): string {
|
||||
case "STARTED":
|
||||
return "In progress";
|
||||
case "QUEUED":
|
||||
case "WAITING_TO_EXECUTE":
|
||||
return "Queued";
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "WAITING_TO_CONTINUE":
|
||||
return "Waiting";
|
||||
case "FAILURE":
|
||||
return "Failed";
|
||||
case "TIMED_OUT":
|
||||
@@ -130,9 +101,12 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
|
||||
case "PENDING":
|
||||
return "text-slate-500";
|
||||
case "STARTED":
|
||||
case "EXECUTING":
|
||||
case "WAITING_TO_CONTINUE":
|
||||
case "WAITING_TO_EXECUTE":
|
||||
return "text-blue-500";
|
||||
case "QUEUED":
|
||||
return "text-amber-300";
|
||||
return "text-slate-500";
|
||||
case "FAILURE":
|
||||
case "UNRESOLVED_AUTH":
|
||||
case "INVALID_PAYLOAD":
|
||||
@@ -147,5 +121,9 @@ export function runStatusClassNameColor(status: JobRunStatus): string {
|
||||
return "text-blue-500";
|
||||
case "CANCELED":
|
||||
return "text-slate-500";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { formatDuration } from "~/utils";
|
||||
import { formatDuration, formatDurationMilliseconds } from "~/utils";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
@@ -20,14 +20,16 @@ import { RunStatus } from "./RunStatuses";
|
||||
|
||||
type RunTableItem = {
|
||||
id: string;
|
||||
number: number;
|
||||
number: number | null;
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType;
|
||||
};
|
||||
job: { title: string; slug: string };
|
||||
status: JobRunStatus;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date | null;
|
||||
executionDuration: number;
|
||||
version: string;
|
||||
isTest: boolean;
|
||||
};
|
||||
@@ -35,6 +37,7 @@ type RunTableItem = {
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
hasFilters: boolean;
|
||||
showJob?: boolean;
|
||||
runs: RunTableItem[];
|
||||
isLoading?: boolean;
|
||||
runsParentPath: string;
|
||||
@@ -45,6 +48,7 @@ export function RunsTable({
|
||||
hasFilters,
|
||||
runs,
|
||||
isLoading = false,
|
||||
showJob = false,
|
||||
runsParentPath,
|
||||
}: RunsTableProps) {
|
||||
return (
|
||||
@@ -52,10 +56,12 @@ export function RunsTable({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Run</TableHeaderCell>
|
||||
{showJob && <TableHeaderCell>Job</TableHeaderCell>}
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Started</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Exec Time</TableHeaderCell>
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Created at</TableHeaderCell>
|
||||
@@ -66,19 +72,24 @@ export function RunsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<NoRuns title="No Runs found for this Job" />
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
<NoRuns title="No Runs found" />
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
<NoRuns title="No Runs match your filters" />
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const path = `${runsParentPath}/${run.id}/trigger`;
|
||||
const path = showJob
|
||||
? `${runsParentPath}/jobs/${run.job.slug}/runs/${run.id}/trigger`
|
||||
: `${runsParentPath}/${run.id}/trigger`;
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell to={path}>#{run.number}</TableCell>
|
||||
<TableCell to={path}>
|
||||
{typeof run.number === "number" ? `#${run.number}` : "-"}
|
||||
</TableCell>
|
||||
{showJob && <TableCell to={path}>{run.job.slug}</TableCell>}
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel environment={run.environment} />
|
||||
</TableCell>
|
||||
@@ -93,6 +104,11 @@ export function RunsTable({
|
||||
style: "short",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{formatDurationMilliseconds(run.executionDuration, {
|
||||
style: "short",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.isTest ? (
|
||||
<CheckIcon className="h-4 w-4 text-slate-400" />
|
||||
@@ -121,6 +137,7 @@ export function RunsTable({
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function NoRuns({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { formatDuration } from "~/utils";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "../primitives/Table";
|
||||
import { RunStatus } from "./RunStatuses";
|
||||
|
||||
type RunTableItem = {
|
||||
id: string;
|
||||
number: number;
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType;
|
||||
};
|
||||
error: string | null;
|
||||
createdAt: Date | null;
|
||||
deliveredAt: Date | null;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
hasFilters: boolean;
|
||||
runs: RunTableItem[];
|
||||
isLoading?: boolean;
|
||||
runsParentPath: string;
|
||||
};
|
||||
|
||||
export function WebhookDeliveryRunsTable({
|
||||
total,
|
||||
hasFilters,
|
||||
runs,
|
||||
isLoading = false,
|
||||
runsParentPath,
|
||||
}: RunsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Run</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Last Error</TableHeaderCell>
|
||||
<TableHeaderCell>Started</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Verified</TableHeaderCell>
|
||||
<TableHeaderCell>Created at</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<NoRuns title="No Runs found for this Job" />
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<NoRuns title="No Runs match your filters" />
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell>#{run.number}</TableCell>
|
||||
<TableCell>
|
||||
<EnvironmentLabel environment={run.environment} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RunStatus
|
||||
status={
|
||||
!run.deliveredAt
|
||||
? "STARTED"
|
||||
: run.error || !run.verified
|
||||
? "FAILURE"
|
||||
: "SUCCESS"
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{run.error?.slice(0, 30) ?? "–"}</TableCell>
|
||||
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}</TableCell>
|
||||
<TableCell>
|
||||
{formatDuration(run.createdAt, run.deliveredAt, {
|
||||
style: "short",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.verified ? (
|
||||
<CheckIcon className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<StopIcon className="h-4 w-4 text-slate-850" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-slate-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
function NoRuns({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +275,31 @@ function ButtonList({ primary }: { primary: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Extra Large buttons</Header1>
|
||||
<div className="grid grid-cols-1 gap-8 border-b border-slate-700 pb-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button variant="primary/extra-large" fullWidth>
|
||||
<NamedIcon name={"github"} className={"mr-1.5 h-5 w-5"} />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button variant="secondary/extra-large" fullWidth>
|
||||
<NamedIcon
|
||||
name={"envelope"}
|
||||
className={"mr-1.5 h-5 w-5 transition group-hover:text-bright"}
|
||||
/>
|
||||
Continue with Email
|
||||
</Button>
|
||||
<Button variant="danger/extra-large" fullWidth>
|
||||
<NamedIcon
|
||||
name={"trash-can"}
|
||||
className={"mr-1.5 h-5 w-5 text-bright transition group-hover:text-bright"}
|
||||
/>
|
||||
This is a delete button
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Menu items</Header1>
|
||||
<div className="grid grid-cols-1">
|
||||
<div className="flex flex-col items-start gap-1 rounded border border-slate-800 bg-slate-850 p-1">
|
||||
|
||||
@@ -8,4 +8,4 @@ export const EXECUTE_JOB_RETRY_LIMIT = 10;
|
||||
export const MAX_RUN_YIELDED_EXECUTIONS = 100;
|
||||
export const RUN_CHUNK_EXECUTION_BUFFER = 350;
|
||||
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
|
||||
export const RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
|
||||
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
|
||||
|
||||
@@ -18,14 +18,7 @@ const EnvironmentSchema = z.object({
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ENV: z
|
||||
.union([
|
||||
z.literal("development"),
|
||||
z.literal("production"),
|
||||
z.literal("test"),
|
||||
z.literal("staging"),
|
||||
])
|
||||
.default(process.env.NODE_ENV),
|
||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
|
||||
POSTHOG_PROJECT_KEY: z.string().optional(),
|
||||
TELEMETRY_TRIGGER_API_KEY: z.string().optional(),
|
||||
@@ -59,6 +52,18 @@ const EnvironmentSchema = z.object({
|
||||
AWS_SQS_QUEUE_URL: z.string().optional(),
|
||||
AWS_SQS_BATCH_SIZE: z.coerce.number().int().optional().default(10),
|
||||
DISABLE_SSE: z.string().optional(),
|
||||
|
||||
// Redis options
|
||||
REDIS_HOST: z.string().optional(),
|
||||
REDIS_READER_HOST: z.string().optional(),
|
||||
REDIS_READER_PORT: z.coerce.number().optional(),
|
||||
REDIS_PORT: z.coerce.number().optional(),
|
||||
REDIS_USERNAME: z.string().optional(),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
REDIS_TLS_DISABLED: z.string().optional(),
|
||||
|
||||
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||||
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { VERCEL_RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { prisma } from "~/db.server";
|
||||
import { Prettify } from "~/lib.es5";
|
||||
|
||||
@@ -20,13 +20,33 @@ export async function findEndpoint(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function detectResponseIsTimeout(response?: Response) {
|
||||
export function detectResponseIsTimeout(rawBody: string, response?: Response) {
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
|
||||
isResponseVercelTimeout(response) ||
|
||||
isResponseDenoDeployTimeout(rawBody, response) ||
|
||||
isResponseCloudflareTimeout(rawBody, response)
|
||||
);
|
||||
}
|
||||
|
||||
function isResponseCloudflareTimeout(rawBody: string, response: Response) {
|
||||
return (
|
||||
response.status === 503 &&
|
||||
rawBody.includes("Worker exceeded resource limits") &&
|
||||
typeof response.headers.get("cf-ray") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isResponseVercelTimeout(response: Response) {
|
||||
return (
|
||||
VERCEL_RESPONSE_TIMEOUT_STATUS_CODES.includes(response.status) ||
|
||||
response.headers.get("x-vercel-error") === "FUNCTION_INVOCATION_TIMEOUT"
|
||||
);
|
||||
}
|
||||
|
||||
function isResponseDenoDeployTimeout(rawBody: string, response: Response) {
|
||||
return response.status === 502 && rawBody.includes("TIME_LIMIT");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { JobRun, JobRunStatus } from "@trigger.dev/database";
|
||||
|
||||
const COMPLETED_STATUSES: Array<JobRun["status"]> = [
|
||||
"CANCELED",
|
||||
"ABORTED",
|
||||
"SUCCESS",
|
||||
"TIMED_OUT",
|
||||
"INVALID_PAYLOAD",
|
||||
"FAILURE",
|
||||
"UNRESOLVED_AUTH",
|
||||
];
|
||||
|
||||
export function isRunCompleted(status: JobRunStatus) {
|
||||
return COMPLETED_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
export type RunBasicStatus = "WAITING" | "PENDING" | "RUNNING" | "COMPLETED" | "FAILED";
|
||||
|
||||
export function runBasicStatus(status: JobRunStatus): RunBasicStatus {
|
||||
switch (status) {
|
||||
case "WAITING_ON_CONNECTIONS":
|
||||
case "QUEUED":
|
||||
case "PREPROCESSING":
|
||||
case "PENDING":
|
||||
return "PENDING";
|
||||
case "STARTED":
|
||||
case "EXECUTING":
|
||||
case "WAITING_TO_CONTINUE":
|
||||
case "WAITING_TO_EXECUTE":
|
||||
return "RUNNING";
|
||||
case "FAILURE":
|
||||
case "TIMED_OUT":
|
||||
case "UNRESOLVED_AUTH":
|
||||
case "CANCELED":
|
||||
case "ABORTED":
|
||||
case "INVALID_PAYLOAD":
|
||||
return "FAILED";
|
||||
case "SUCCESS":
|
||||
return "COMPLETED";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runOriginalStatus(status: JobRunStatus) {
|
||||
switch (status) {
|
||||
case "EXECUTING":
|
||||
case "WAITING_TO_CONTINUE":
|
||||
case "WAITING_TO_EXECUTE":
|
||||
return "STARTED";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { JobRun } from "@trigger.dev/database";
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { executionWorker } from "~/services/worker.server";
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
projectName,
|
||||
}: Pick<Organization, "title"> & {
|
||||
companySize,
|
||||
}: Pick<Organization, "title" | "companySize"> & {
|
||||
userId: User["id"];
|
||||
projectName: string;
|
||||
},
|
||||
@@ -47,6 +48,7 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
projectName,
|
||||
companySize,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -56,6 +58,7 @@ export async function createOrganization(
|
||||
data: {
|
||||
title,
|
||||
slug: uniqueOrgSlug,
|
||||
companySize,
|
||||
members: {
|
||||
create: {
|
||||
userId: userId,
|
||||
|
||||
@@ -162,12 +162,14 @@ export function updateUser({
|
||||
name,
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
}: Pick<User, "id" | "name" | "email"> & {
|
||||
marketingEmails?: boolean;
|
||||
referralSource?: string;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { name, email, marketingEmails, confirmedBasicDetails: true },
|
||||
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +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 { PgListenService } from "~/services/db/pgListen.server";
|
||||
import { workerLogger as logger, trace } from "~/services/logger.server";
|
||||
|
||||
export interface MessageCatalogSchema {
|
||||
@@ -93,6 +94,11 @@ export type ZodWorkerCleanupOptions = {
|
||||
|
||||
type ZodWorkerReporter = (event: string, properties: Record<string, any>) => Promise<void>;
|
||||
|
||||
export interface ZodWorkerRateLimiter {
|
||||
forbiddenFlags(): Promise<string[]>;
|
||||
wrapTask(t: Task, rescheduler: Task): Task;
|
||||
}
|
||||
|
||||
export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
name: string;
|
||||
runnerOptions: RunnerOptions;
|
||||
@@ -103,6 +109,7 @@ export type ZodWorkerOptions<TMessageCatalog extends MessageCatalogSchema> = {
|
||||
cleanup?: ZodWorkerCleanupOptions;
|
||||
reporter?: ZodWorkerReporter;
|
||||
shutdownTimeoutInMs?: number;
|
||||
rateLimiter?: ZodWorkerRateLimiter;
|
||||
};
|
||||
|
||||
export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
@@ -115,6 +122,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#runner?: GraphileRunner;
|
||||
#cleanup: ZodWorkerCleanupOptions | undefined;
|
||||
#reporter?: ZodWorkerReporter;
|
||||
#rateLimiter?: ZodWorkerRateLimiter;
|
||||
#shutdownTimeoutInMs?: number;
|
||||
#shuttingDown = false;
|
||||
|
||||
@@ -127,6 +135,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#recurringTasks = options.recurringTasks;
|
||||
this.#cleanup = options.cleanup;
|
||||
this.#reporter = options.reporter;
|
||||
this.#rateLimiter = options.rateLimiter;
|
||||
this.#shutdownTimeoutInMs = options.shutdownTimeoutInMs ?? 60000; // default to 60 seconds
|
||||
}
|
||||
|
||||
@@ -150,6 +159,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
noHandleSignals: true,
|
||||
taskList: this.#createTaskListFromTasks(),
|
||||
parsedCronItems,
|
||||
forbiddenFlags: this.#rateLimiter?.forbiddenFlags.bind(this.#rateLimiter),
|
||||
});
|
||||
|
||||
if (!this.#runner) {
|
||||
@@ -166,6 +176,21 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
this.#runner?.events.on("pool:listen:success", async ({ workerPool, client }) => {
|
||||
this.#logDebug("pool:listen:success");
|
||||
|
||||
// hijack client instance to listen and react to incoming NOTIFY events
|
||||
const pgListen = new PgListenService(client, this.#name, logger);
|
||||
|
||||
await pgListen.on("trigger:graphile:migrate", async ({ latestMigration }) => {
|
||||
this.#logDebug("Detected incoming migration", { latestMigration });
|
||||
|
||||
if (latestMigration > 10) {
|
||||
// already migrated past v0.14 - nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
// simulate SIGTERM to trigger graceful shutdown
|
||||
this._handleSignal("SIGTERM");
|
||||
});
|
||||
});
|
||||
|
||||
this.#runner?.events.on("pool:listen:error", ({ error }) => {
|
||||
@@ -379,7 +404,11 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return this.#handleMessage(key, payload, helpers);
|
||||
};
|
||||
|
||||
taskList[key] = task;
|
||||
if (this.#rateLimiter) {
|
||||
taskList[key] = this.#rateLimiter.wrapTask(task, this.#rescheduleTask.bind(this));
|
||||
} else {
|
||||
taskList[key] = task;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key] of Object.entries(this.#recurringTasks ?? {})) {
|
||||
@@ -409,6 +438,19 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return taskList;
|
||||
}
|
||||
|
||||
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
|
||||
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
|
||||
|
||||
await this.enqueue(helpers.job.task_identifier, payload, {
|
||||
runAt: helpers.job.run_at,
|
||||
queueName: helpers.job.queue_name ?? undefined,
|
||||
priority: helpers.job.priority,
|
||||
jobKey: helpers.job.key ?? undefined,
|
||||
flags: Object.keys(helpers.job.flags ?? []),
|
||||
maxAttempts: helpers.job.max_attempts,
|
||||
});
|
||||
}
|
||||
|
||||
#createCronItemsFromRecurringTasks() {
|
||||
const cronItems: CronItem[] = [];
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export class HttpEndpointPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -17,10 +15,12 @@ export class HttpEndpointPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
httpEndpointKey,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
httpEndpointKey: string;
|
||||
}) {
|
||||
const httpEndpoint = await this.#prismaClient.triggerHttpEndpoint.findFirst({
|
||||
@@ -57,6 +57,12 @@ export class HttpEndpointPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
key: httpEndpointKey,
|
||||
@@ -138,11 +144,15 @@ export class HttpEndpointPresenter {
|
||||
?.webhookUrl,
|
||||
}));
|
||||
|
||||
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
|
||||
|
||||
return {
|
||||
httpEndpoint: {
|
||||
...httpEndpoint,
|
||||
|
||||
httpEndpointEnvironments,
|
||||
webhookLink: httpEndpoint.webhook
|
||||
? `${projectRootPath}/triggers/webhooks/${httpEndpoint.webhook.id}`
|
||||
: undefined,
|
||||
},
|
||||
environments: relevantEnvironments,
|
||||
unconfiguredEnvironments: relevantEnvironments.filter(
|
||||
|
||||
@@ -43,6 +43,13 @@ export class JobPresenter {
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
concurrencyLimit: true,
|
||||
concurrencyLimitGroup: {
|
||||
select: {
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
@@ -186,6 +193,8 @@ export class JobPresenter {
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
concurrencyLimit: alias.version.concurrencyLimit,
|
||||
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
|
||||
}));
|
||||
|
||||
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { DirectionSchema } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
|
||||
export type Direction = z.infer<typeof DirectionSchema>;
|
||||
|
||||
type RunListOptions = {
|
||||
userId: string;
|
||||
jobSlug: string;
|
||||
jobSlug?: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
|
||||
|
||||
@@ -31,9 +32,45 @@ export class RunListPresenter {
|
||||
projectSlug,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: RunListOptions) {
|
||||
const directionMultiplier = direction === "forward" ? 1 : -1;
|
||||
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Find all runtimeEnvironments that the user has access to
|
||||
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
OR: [
|
||||
{ orgMember: { userId } },
|
||||
{ orgMemberId: null },
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const job = jobSlug ? await this.#prismaClient.job.findFirstOrThrow({
|
||||
where: {
|
||||
slug: jobSlug,
|
||||
projectId: project.id,
|
||||
},
|
||||
}) : undefined;
|
||||
|
||||
const runs = await this.#prismaClient.jobRun.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -41,6 +78,7 @@ export class RunListPresenter {
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
createdAt: true,
|
||||
executionDuration: true,
|
||||
isTest: true,
|
||||
status: true,
|
||||
environment: {
|
||||
@@ -59,41 +97,34 @@ export class RunListPresenter {
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
job: {
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
job: {
|
||||
slug: jobSlug,
|
||||
},
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
organization: { slug: organizationSlug, members: { some: { userId } } },
|
||||
environment: {
|
||||
OR: [
|
||||
{
|
||||
orgMember: null,
|
||||
},
|
||||
{
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
jobId: job?.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentId: {
|
||||
in: environments.map((environment) => environment.id),
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//take an extra page to tell if there are more
|
||||
take: directionMultiplier * (PAGE_SIZE + 1),
|
||||
//take an extra record to tell if there are more
|
||||
take: directionMultiplier * (pageSize + 1),
|
||||
//skip the cursor if there is one
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor
|
||||
? {
|
||||
id: cursor,
|
||||
}
|
||||
id: cursor,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const hasMore = runs.length > PAGE_SIZE;
|
||||
const hasMore = runs.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
@@ -102,19 +133,21 @@ export class RunListPresenter {
|
||||
case "forward":
|
||||
previous = cursor ? runs.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
next = runs[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
previous = runs[1]?.id;
|
||||
next = runs[pageSize]?.id;
|
||||
} else {
|
||||
next = runs[pageSize - 1]?.id;
|
||||
}
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
break;
|
||||
}
|
||||
|
||||
const runsToReturn =
|
||||
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
|
||||
direction === "backward" && hasMore ? runs.slice(1, pageSize + 1) : runs.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
runs: runsToReturn.map((run) => ({
|
||||
@@ -123,6 +156,7 @@ export class RunListPresenter {
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
createdAt: run.createdAt,
|
||||
executionDuration: run.executionDuration,
|
||||
isTest: run.isTest,
|
||||
status: run.status,
|
||||
version: run.version?.version ?? "unknown",
|
||||
@@ -131,6 +165,7 @@ export class RunListPresenter {
|
||||
slug: run.environment.slug,
|
||||
userId: run.environment.orgMember?.userId,
|
||||
},
|
||||
job: run.job,
|
||||
})),
|
||||
pagination: {
|
||||
next,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
StyleSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { isRunCompleted, runBasicStatus } from "~/models/jobRun.server";
|
||||
import { mergeProperties } from "~/utils/mergeProperties.server";
|
||||
import { taskListToTree } from "~/utils/taskListToTree";
|
||||
|
||||
@@ -67,6 +68,8 @@ export class RunPresenter {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
status: run.status,
|
||||
basicStatus: runBasicStatus(run.status),
|
||||
isFinished: isRunCompleted(run.status),
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
isTest: run.isTest,
|
||||
@@ -82,6 +85,8 @@ export class RunPresenter {
|
||||
runConnections: run.runConnections,
|
||||
missingConnections: run.missingConnections,
|
||||
error: runError,
|
||||
executionDuration: run.executionDuration,
|
||||
executionCount: run.executionCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,6 +117,8 @@ export class RunPresenter {
|
||||
isTest: true,
|
||||
properties: true,
|
||||
output: true,
|
||||
executionCount: true,
|
||||
executionDuration: true,
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Direction } from "./RunListPresenter.server";
|
||||
|
||||
type RunListOptions = {
|
||||
userId: string;
|
||||
webhookId: string;
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export type WebhookDeliveryList = Awaited<ReturnType<WebhookDeliveryListPresenter["call"]>>;
|
||||
|
||||
export class WebhookDeliveryListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, webhookId, direction = "forward", cursor }: RunListOptions) {
|
||||
const directionMultiplier = direction === "forward" ? 1 : -1;
|
||||
|
||||
const runs = await this.#prismaClient.webhookRequestDelivery.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
createdAt: true,
|
||||
deliveredAt: true,
|
||||
verified: true,
|
||||
error: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
webhookId,
|
||||
environment: {
|
||||
OR: [
|
||||
{
|
||||
orgMember: null,
|
||||
},
|
||||
{
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//take an extra page to tell if there are more
|
||||
take: directionMultiplier * (PAGE_SIZE + 1),
|
||||
//skip the cursor if there is one
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor
|
||||
? {
|
||||
id: cursor,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const hasMore = runs.length > PAGE_SIZE;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? runs.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
previous = runs[1]?.id;
|
||||
next = runs[PAGE_SIZE]?.id;
|
||||
} else {
|
||||
next = runs[PAGE_SIZE - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const runsToReturn =
|
||||
direction === "backward" && hasMore ? runs.slice(1, PAGE_SIZE + 1) : runs.slice(0, PAGE_SIZE);
|
||||
|
||||
return {
|
||||
runs: runsToReturn.map((run) => ({
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
createdAt: run.createdAt,
|
||||
deliveredAt: run.deliveredAt,
|
||||
verified: run.verified,
|
||||
error: run.error,
|
||||
environment: {
|
||||
type: run.environment.type,
|
||||
slug: run.environment.slug,
|
||||
userId: run.environment.orgMember?.userId,
|
||||
},
|
||||
})),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { User, Webhook } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { Direction } from "./RunListPresenter.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { WebhookDeliveryListPresenter } from "./WebhookDeliveryListPresenter.server";
|
||||
|
||||
export class WebhookDeliveryPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
webhookId,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
webhookId: Webhook["id"];
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
}) {
|
||||
const webhook = await this.#prismaClient.webhook.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
active: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
definitionId: true,
|
||||
setupStatus: true,
|
||||
definition: {
|
||||
select: {
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
httpEndpoint: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
params: true,
|
||||
},
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error("Webhook source not found");
|
||||
}
|
||||
|
||||
const deliveryListPresenter = new WebhookDeliveryListPresenter(this.#prismaClient);
|
||||
|
||||
const orgRootPath = organizationPath({ slug: organizationSlug });
|
||||
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
|
||||
|
||||
const requestDeliveries = await deliveryListPresenter.call({
|
||||
userId,
|
||||
webhookId: webhook.id,
|
||||
direction,
|
||||
cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
webhook: {
|
||||
id: webhook.id,
|
||||
key: webhook.key,
|
||||
active: webhook.active,
|
||||
integration: webhook.integration,
|
||||
integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
|
||||
httpEndpoint: webhook.httpEndpoint,
|
||||
httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
|
||||
createdAt: webhook.createdAt,
|
||||
updatedAt: webhook.updatedAt,
|
||||
params: webhook.params,
|
||||
requestDeliveries,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { User, Webhook } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { Direction, RunListPresenter } from "./RunListPresenter.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export class WebhookSourcePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
webhookId,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
getDeliveryRuns = false,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
webhookId: Webhook["id"];
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
getDeliveryRuns?: boolean;
|
||||
}) {
|
||||
const webhook = await this.#prismaClient.webhook.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
active: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
definitionId: true,
|
||||
setupStatus: true,
|
||||
definition: {
|
||||
select: {
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
httpEndpoint: {
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
},
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
params: true,
|
||||
},
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new Error("Webhook source not found");
|
||||
}
|
||||
|
||||
const runListPresenter = new RunListPresenter(this.#prismaClient);
|
||||
const jobSlug = getDeliveryRuns
|
||||
? getDeliveryJobSlug(webhook.key)
|
||||
: getRegistrationJobSlug(webhook.key);
|
||||
|
||||
const runList = await runListPresenter.call({
|
||||
userId,
|
||||
jobSlug,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
direction,
|
||||
cursor,
|
||||
});
|
||||
|
||||
const orgRootPath = organizationPath({ slug: organizationSlug });
|
||||
const projectRootPath = projectPath({ slug: organizationSlug }, { slug: projectSlug });
|
||||
|
||||
return {
|
||||
trigger: {
|
||||
id: webhook.id,
|
||||
key: webhook.key,
|
||||
active: webhook.active,
|
||||
integration: webhook.integration,
|
||||
integrationLink: `${orgRootPath}/integrations/${webhook.integration.slug}`,
|
||||
httpEndpoint: webhook.httpEndpoint,
|
||||
httpEndpointLink: `${projectRootPath}/http-endpoints/${webhook.httpEndpoint.key}`,
|
||||
createdAt: webhook.createdAt,
|
||||
updatedAt: webhook.updatedAt,
|
||||
params: webhook.params,
|
||||
runList,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getRegistrationJobSlug = (key: string) => `webhook.register.${key}`;
|
||||
|
||||
const getDeliveryJobSlug = (key: string) => `webhook.deliver.${key}`;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Organization, User } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
|
||||
export class WebhookTriggersPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
}) {
|
||||
const webhooks = await this.#prismaClient.webhook.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
active: true,
|
||||
params: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
definitionId: true,
|
||||
setupStatus: true,
|
||||
definition: {
|
||||
select: {
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
webhookEnvironments: {
|
||||
select: {
|
||||
id: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { webhooks };
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PageTitleRow,
|
||||
PageTitle,
|
||||
PageButtons,
|
||||
PageInfoRow,
|
||||
PageInfoGroup,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -38,6 +40,13 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageInfoRow>
|
||||
<PageInfoGroup alignment="right">
|
||||
<Paragraph variant="extra-small" className="text-slate-600">
|
||||
UID: {organization.id}
|
||||
</Paragraph>
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ export default function Integrations() {
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
<PageTabs tabs={tabs} />
|
||||
<PageTabs layoutId="integrations" tabs={tabs} />
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={true}>
|
||||
|
||||
+8
-6
@@ -105,12 +105,14 @@ export default function Page() {
|
||||
};
|
||||
}, [selected, clients]);
|
||||
|
||||
const isAnyClientFullyConfigured = useMemo(() => {
|
||||
return clients.some((client) => {
|
||||
const { DEVELOPMENT, PRODUCTION } = client.endpoints;
|
||||
return PRODUCTION.state === "configured" && DEVELOPMENT.state === PRODUCTION.state;
|
||||
});
|
||||
}, [clients]);
|
||||
const isAnyClientFullyConfigured = clients.some((client) => {
|
||||
const { DEVELOPMENT, PRODUCTION, STAGING } = client.endpoints;
|
||||
return (
|
||||
PRODUCTION.state === "configured" ||
|
||||
DEVELOPMENT.state === "configured" ||
|
||||
(STAGING && STAGING.state === "configured")
|
||||
);
|
||||
});
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
+17
-1
@@ -14,6 +14,9 @@ import { Help, HelpContent, HelpTrigger } from "~/components/primitives/Help";
|
||||
import {
|
||||
PageButtons,
|
||||
PageHeader,
|
||||
PageInfoGroup,
|
||||
PageInfoProperty,
|
||||
PageInfoRow,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
@@ -38,13 +41,15 @@ import { HttpEndpointParamSchema, docsPath, projectHttpEndpointsPath } from "~/u
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, httpEndpointParam } = HttpEndpointParamSchema.parse(params);
|
||||
const { projectParam, organizationSlug, httpEndpointParam } =
|
||||
HttpEndpointParamSchema.parse(params);
|
||||
|
||||
const presenter = new HttpEndpointPresenter();
|
||||
try {
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
httpEndpointKey: httpEndpointParam,
|
||||
});
|
||||
|
||||
@@ -98,6 +103,17 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
{httpEndpoint.webhook && (
|
||||
<PageInfoRow>
|
||||
<PageInfoGroup>
|
||||
<PageInfoProperty
|
||||
icon="webhook"
|
||||
label="Webhook Trigger"
|
||||
to={httpEndpoint.webhookLink}
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageBody>
|
||||
<Help defaultOpen={true}>
|
||||
|
||||
+24
-9
@@ -1,9 +1,16 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Direction, RunList } from "~/presenters/RunListPresenter.server";
|
||||
import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function ListPagination({ list, className }: { list: RunList; className?: string }) {
|
||||
export function ListPagination({
|
||||
list,
|
||||
className,
|
||||
}: {
|
||||
list: RunList | WebhookDeliveryList;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<PreviousButton cursor={list.pagination.previous} />
|
||||
@@ -15,31 +22,39 @@ export function ListPagination({ list, className }: { list: RunList; className?:
|
||||
function NextButton({ cursor }: { cursor?: string }) {
|
||||
const path = useCursorPath(cursor, "forward");
|
||||
|
||||
return path ? (
|
||||
return (
|
||||
<LinkButton
|
||||
to={path}
|
||||
to={path ?? "#"}
|
||||
variant={"tertiary/small"}
|
||||
TrailingIcon="chevron-right"
|
||||
className="flex items-center"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
>
|
||||
Next
|
||||
</LinkButton>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousButton({ cursor }: { cursor?: string }) {
|
||||
const path = useCursorPath(cursor, "backward");
|
||||
|
||||
return path ? (
|
||||
return (
|
||||
<LinkButton
|
||||
to={path}
|
||||
to={path ?? "#"}
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon="chevron-left"
|
||||
className="flex items-center"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
>
|
||||
Prev
|
||||
</LinkButton>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
function useCursorPath(cursor: string | undefined, direction: Direction) {
|
||||
|
||||
+1
-1
@@ -72,8 +72,8 @@ export default function Page() {
|
||||
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
<HelpTrigger title="How do I run my Job?" />
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
<RunsTable
|
||||
total={list.runs.length}
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ export default function Page() {
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<Help defaultOpen>
|
||||
<Help>
|
||||
{(open) => (
|
||||
<div className={cn("grid h-fit gap-4", open ? "grid-cols-2" : "grid-cols-1")}>
|
||||
<div className="w-full">
|
||||
@@ -32,7 +32,7 @@ export default function Page() {
|
||||
<Header2 className="mb-2 flex items-center gap-1">Environments</Header2>
|
||||
<HelpTrigger title="How do disable a Job?" />
|
||||
</div>
|
||||
<JobStatusTable environments={job.environments} />
|
||||
<JobStatusTable environments={job.environments} displayStyle="long" />
|
||||
<div className="mt-4 flex w-full items-center justify-end gap-x-3">
|
||||
{job.status === "ACTIVE" && (
|
||||
<Paragraph variant="small">
|
||||
|
||||
+3
-1
@@ -297,7 +297,9 @@ export default function Page() {
|
||||
label={<DateTime date={run.created} />}
|
||||
description={
|
||||
<>
|
||||
Run #{run.number}{" "}
|
||||
{typeof run.number === "number"
|
||||
? `Run #${run.number}`
|
||||
: `Run ${run.id.slice(0, 8)}`}
|
||||
<span className={runStatusClassNameColor(run.status)}>
|
||||
{runStatusTitle(run.status).toLocaleLowerCase()}
|
||||
</span>
|
||||
|
||||
+1
@@ -154,6 +154,7 @@ export default function Job() {
|
||||
)}
|
||||
|
||||
<PageTabs
|
||||
layoutId="jobs"
|
||||
tabs={[
|
||||
{ label: "Runs", to: jobPath(organization, project, job) },
|
||||
{ label: "Test", to: jobTestPath(organization, project, job) },
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
PageButtons,
|
||||
PageDescription,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { RunListPresenter } from "~/presenters/RunListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
organizationSlug,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
pageSize: 25,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
list,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { list } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${project.name} Runs`} />
|
||||
<PageButtons>
|
||||
<LinkButton
|
||||
LeadingIcon={"docs"}
|
||||
to={docsPath("documentation/concepts/runs")}
|
||||
variant="secondary/small"
|
||||
>
|
||||
Run documentation
|
||||
</LinkButton>
|
||||
</PageButtons>
|
||||
</PageTitleRow>
|
||||
<PageDescription>All Job Runs in this project</PageDescription>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mb-2 flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
<RunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={false}
|
||||
showJob={true}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
runsParentPath={projectPath(organization, project)}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { LabelValueStack } from "~/components/primitives/LabelValueStack";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellChevron,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { WebhookTriggersPresenter } from "~/presenters/WebhookTriggersPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { ProjectParamSchema, trimTrailingSlash, webhookTriggerPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const presenter = new WebhookTriggersPresenter();
|
||||
const data = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
return typedjson(data);
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => (
|
||||
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Webhook Triggers" />
|
||||
),
|
||||
};
|
||||
|
||||
export default function Integrations() {
|
||||
const { webhooks } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paragraph variant="small" spacing>
|
||||
A Webhook Trigger runs a Job when it receives a matching payload at a registered HTTP Endpoint.
|
||||
</Paragraph>
|
||||
|
||||
<Table containerClassName="mt-4">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Key</TableHeaderCell>
|
||||
<TableHeaderCell>Integration</TableHeaderCell>
|
||||
<TableHeaderCell>Properties</TableHeaderCell>
|
||||
<TableHeaderCell>Environment</TableHeaderCell>
|
||||
<TableHeaderCell>Active</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{webhooks.length > 0 ? (
|
||||
webhooks.map((w) => {
|
||||
const path = webhookTriggerPath(organization, project, w);
|
||||
return (
|
||||
<TableRow key={w.id} className={cn(!w.active && "bg-rose-500/30")}>
|
||||
<TableCell to={path}>{w.key}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-1">
|
||||
<NamedIcon
|
||||
name={w.integration.definition.icon ?? w.integration.definitionId}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<LabelValueStack
|
||||
label={w.integration.title}
|
||||
value={w.integration.slug}
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{w.params && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
|
||||
{Object.entries(w.params).map(([label, value], index) => (
|
||||
<LabelValueStack
|
||||
key={index}
|
||||
label={label}
|
||||
value={value}
|
||||
className="last:truncate"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
content={
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(w.params).map(([label, value], index) => (
|
||||
<LabelValueStack key={index} label={label} value={value} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{w.webhookEnvironments.map((env) => (
|
||||
<EnvironmentLabel
|
||||
key={env.id}
|
||||
environment={env.environment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{w.active ? (
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="h-6 w-6 text-rose-500" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={100}>
|
||||
<Paragraph>No External triggers</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+6
@@ -17,6 +17,7 @@ import {
|
||||
docsPath,
|
||||
projectScheduledTriggersPath,
|
||||
projectTriggersPath,
|
||||
projectWebhookTriggersPath,
|
||||
trimTrailingSlash,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
@@ -45,6 +46,7 @@ export default function Page() {
|
||||
</PageTitleRow>
|
||||
<PageDescription>A Trigger is what starts a Job Run.</PageDescription>
|
||||
<PageTabs
|
||||
layoutId="triggers"
|
||||
tabs={[
|
||||
{
|
||||
label: "External Triggers",
|
||||
@@ -54,6 +56,10 @@ export default function Page() {
|
||||
label: "Scheduled Triggers",
|
||||
to: projectScheduledTriggersPath(organization, project),
|
||||
},
|
||||
{
|
||||
label: "Webhook Triggers",
|
||||
to: projectWebhookTriggersPath(organization, project),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RunsTable } from "~/components/runs/RunsTable";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectTriggersPath,
|
||||
externalTriggerPath,
|
||||
trimTrailingSlash,
|
||||
webhookTriggerRunsParentPath,
|
||||
projectWebhookTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { z } from "zod";
|
||||
import { ActivateSourceService } from "~/services/sources/activateSource.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new WebhookSourcePresenter();
|
||||
const { trigger } = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
});
|
||||
|
||||
if (!trigger) {
|
||||
throw new Response("Trigger not found", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({ trigger });
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
/* export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new ActivateSourceService();
|
||||
|
||||
const result = await service.call(triggerParam);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
externalTriggerPath({ slug: organizationSlug }, { slug: projectParam }, { id: triggerParam }),
|
||||
request,
|
||||
`Retrying registration now`
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
}; */
|
||||
|
||||
export const handle: Handle = {
|
||||
//this one is complicated because we render outside the parent route (using triggers_ in the path)
|
||||
breadcrumb: (match, matches) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
if (!data) return null;
|
||||
|
||||
const org = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink
|
||||
to={trimTrailingSlash(match.pathname)}
|
||||
title={data.trigger.key}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { trigger } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const navigation = useNavigation();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { jobId }] = useForm({
|
||||
id: "trigger-registration-retry",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = navigation.state === "submitting" && navigation.formData !== undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paragraph variant="small" spacing>
|
||||
Webhook Triggers need to be registered with the external service. You can see the list
|
||||
of attempted registrations below.
|
||||
</Paragraph>
|
||||
|
||||
{!trigger.active &&
|
||||
<Form method="post" {...form.props}>
|
||||
<Callout variant="error" className="justiy-between mb-4 items-center">
|
||||
<Paragraph variant="small" className={cn(variantClasses.error.textColor, "grow")}>
|
||||
Registration hasn't succeeded yet, check the runs below.
|
||||
</Paragraph>
|
||||
{/* <input
|
||||
{...conform.input(jobId, { type: "hidden" })}
|
||||
defaultValue={trigger.registrationJob?.id}
|
||||
/>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
type="submit"
|
||||
name={conform.INTENT}
|
||||
value="retry"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
{isLoading ? "Retrying…" : "Retry now"}
|
||||
</Button> */}
|
||||
</Callout>
|
||||
</Form>}
|
||||
|
||||
{trigger.runList ? (
|
||||
<>
|
||||
<ListPagination list={trigger.runList} className="mb-2 justify-end" />
|
||||
<RunsTable
|
||||
runs={trigger.runList.runs}
|
||||
total={trigger.runList.runs.length}
|
||||
hasFilters={false}
|
||||
runsParentPath={webhookTriggerRunsParentPath(organization, project, trigger)}
|
||||
/>
|
||||
<ListPagination list={trigger.runList} className="mt-2 justify-end" />
|
||||
</>
|
||||
) : (
|
||||
<Callout variant="warning">No registration runs found</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectTriggersPath,
|
||||
projectWebhookTriggersPath,
|
||||
trimTrailingSlash,
|
||||
webhookTriggerDeliveryRunsParentPath,
|
||||
webhookTriggerPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
|
||||
import { WebhookDeliveryRunsTable } from "~/components/runs/WebhookDeliveryRunsTable";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new WebhookDeliveryPresenter();
|
||||
const { webhook } = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
});
|
||||
|
||||
if (!webhook) {
|
||||
throw new Response("Trigger not found", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({ webhook });
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
//this one is complicated because we render outside the parent route (using triggers_ in the path)
|
||||
breadcrumb: (match, matches) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
if (!data) return null;
|
||||
|
||||
const org = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BreadcrumbLink to={projectTriggersPath(org, project)} title="Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink
|
||||
to={webhookTriggerPath(org, project, { id: data.webhook.id })}
|
||||
title={data.webhook.key}
|
||||
/>
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={trimTrailingSlash(match.pathname)} title="Deliveries" />
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { webhook } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paragraph variant="small" spacing>
|
||||
Webhook payloads are delivered to clients for validation and event generation. You can see
|
||||
the list of attempted deliveries below.
|
||||
</Paragraph>
|
||||
|
||||
{webhook.requestDeliveries ? (
|
||||
<>
|
||||
<ListPagination list={webhook.requestDeliveries} className="mb-2 justify-end" />
|
||||
<WebhookDeliveryRunsTable
|
||||
runs={webhook.requestDeliveries.runs}
|
||||
total={webhook.requestDeliveries.runs.length}
|
||||
hasFilters={false}
|
||||
runsParentPath={webhookTriggerDeliveryRunsParentPath(organization, project, webhook)}
|
||||
/>
|
||||
<ListPagination list={webhook.requestDeliveries} className="mt-2 justify-end" />
|
||||
</>
|
||||
) : (
|
||||
<Callout variant="warning">No registration runs found</Callout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import {
|
||||
PageHeader,
|
||||
PageInfoGroup,
|
||||
PageInfoProperty,
|
||||
PageInfoRow,
|
||||
PageTabs,
|
||||
PageTitle,
|
||||
PageTitleRow,
|
||||
} from "~/components/primitives/PageHeader";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectWebhookTriggersPath,
|
||||
webhookDeliveryPath,
|
||||
webhookTriggerPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { RunListSearchSchema } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/route";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const searchParams = RunListSearchSchema.parse(s);
|
||||
|
||||
const presenter = new WebhookSourcePresenter();
|
||||
const { trigger } = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
direction: searchParams.direction,
|
||||
cursor: searchParams.cursor,
|
||||
});
|
||||
|
||||
if (!trigger) {
|
||||
throw new Response("Trigger not found", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({ trigger });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { trigger } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader hideBorder>
|
||||
<PageTitleRow>
|
||||
<PageTitle
|
||||
title={trigger.key}
|
||||
backButton={{
|
||||
to: projectWebhookTriggersPath(organization, project),
|
||||
text: "Webhook Triggers",
|
||||
}}
|
||||
/>
|
||||
</PageTitleRow>
|
||||
<PageInfoRow>
|
||||
<PageInfoGroup>
|
||||
<PageInfoProperty
|
||||
icon={trigger.integration.definition.icon ?? trigger.integration.definitionId}
|
||||
label={trigger.integration.title ?? ""}
|
||||
value={trigger.integration.slug}
|
||||
to={trigger.integrationLink}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon="webhook"
|
||||
label="HTTP Endpoint"
|
||||
to={trigger.httpEndpointLink}
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
</PageInfoRow>
|
||||
<PageTabs
|
||||
layoutId="webhook-trigger"
|
||||
tabs={[
|
||||
{
|
||||
label: "Registrations",
|
||||
to: webhookTriggerPath(organization, project, trigger),
|
||||
},
|
||||
{
|
||||
label: "Deliveries",
|
||||
to: webhookDeliveryPath(organization, project, trigger),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody scrollable={false}>
|
||||
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<Outlet />
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
|
||||
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam/route";
|
||||
|
||||
function useTriggerRegisterRun() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof runLoader>(
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.$runParam"
|
||||
);
|
||||
|
||||
if (!routeMatch || !routeMatch.run) {
|
||||
throw new Error("No run found");
|
||||
}
|
||||
|
||||
return routeMatch.run;
|
||||
}
|
||||
|
||||
export default function RunCompletedPage() {
|
||||
const run = useTriggerRegisterRun();
|
||||
return <RunCompletedDetail run={run} />;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
|
||||
const { runParam } = z.object({ runParam: z.string() }).parse(params);
|
||||
|
||||
const presenter = new RunStreamPresenter();
|
||||
return presenter.call({ request, runId: runParam });
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Await, useLoaderData } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskDetail } from "~/components/run/TaskDetail";
|
||||
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TaskDetailsPresenter();
|
||||
const taskPromise = presenter.call({
|
||||
userId,
|
||||
id: taskParam,
|
||||
});
|
||||
|
||||
return defer({
|
||||
taskPromise,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { taskPromise } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
|
||||
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TriggerDetail } from "~/components/run/TriggerDetail";
|
||||
import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
|
||||
import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { runParam } = TriggerSourceRunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TriggerDetailsPresenter();
|
||||
const trigger = await presenter.call(runParam);
|
||||
|
||||
if (!trigger) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { trigger } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<TriggerDetail
|
||||
trigger={trigger}
|
||||
event={{ icon: "webhook", title: "Register Webhook" }}
|
||||
properties={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEventSource } from "remix-utils/sse/react";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { RunOverview } from "~/components/run/RunOverview";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { RunPresenter } from "~/presenters/RunPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceRunParamsSchema,
|
||||
projectWebhookTriggersPath,
|
||||
trimTrailingSlash,
|
||||
webhookTriggerPath,
|
||||
webhookTriggerRunPath,
|
||||
webhookTriggerRunStreamingPath,
|
||||
webhookTriggerRunsParentPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new RunPresenter();
|
||||
const run = await presenter.call({
|
||||
userId,
|
||||
id: runParam,
|
||||
});
|
||||
|
||||
const trigger = await prisma.webhook.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
definitionId: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: triggerParam,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run || !trigger) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
run,
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match, matches) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
if (!data) return null;
|
||||
|
||||
const org = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink
|
||||
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
|
||||
title={data.trigger.key}
|
||||
/>
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink
|
||||
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
|
||||
title="Registrations"
|
||||
/>
|
||||
<BreadcrumbIcon />
|
||||
{data && data.run && (
|
||||
<BreadcrumbLink
|
||||
to={trimTrailingSlash(match.pathname)}
|
||||
title={`Run #${data.run.number}`}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { run, trigger } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const events = useEventSource(
|
||||
webhookTriggerRunStreamingPath(organization, project, trigger, run),
|
||||
{
|
||||
event: "message",
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (events !== null) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<RunOverview
|
||||
run={run}
|
||||
trigger={{ icon: "webhook", title: "Register Webhook" }}
|
||||
showRerun={true}
|
||||
paths={{
|
||||
back: webhookTriggerPath(organization, project, { id: trigger.id }),
|
||||
run: webhookTriggerRunPath(organization, project, { id: trigger.id }, run),
|
||||
runsPath: webhookTriggerRunsParentPath(organization, project, {
|
||||
id: trigger.id,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { RunCompletedDetail } from "~/components/run/RunCompletedDetail";
|
||||
import type { loader as runLoader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam/route";
|
||||
|
||||
function useTriggerRegisterRun() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof runLoader>(
|
||||
"routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.webhooks.$triggerParam_.runs.delivery.$runParam"
|
||||
);
|
||||
|
||||
if (!routeMatch || !routeMatch.run) {
|
||||
throw new Error("No run found");
|
||||
}
|
||||
|
||||
return routeMatch.run;
|
||||
}
|
||||
|
||||
export default function RunCompletedPage() {
|
||||
const run = useTriggerRegisterRun();
|
||||
return <RunCompletedDetail run={run} />;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { RunStreamPresenter } from "~/presenters/RunStreamPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
|
||||
const { runParam } = z.object({ runParam: z.string() }).parse(params);
|
||||
|
||||
const presenter = new RunStreamPresenter();
|
||||
return presenter.call({ request, runId: runParam });
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Await, useLoaderData } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskDetail } from "~/components/run/TaskDetail";
|
||||
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { TriggerSourceRunTaskParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TaskDetailsPresenter();
|
||||
const taskPromise = presenter.call({
|
||||
userId,
|
||||
id: taskParam,
|
||||
});
|
||||
|
||||
return defer({
|
||||
taskPromise,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { taskPromise } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={taskPromise} errorElement={<p>Error loading task!</p>}>
|
||||
{(resolvedTask) => resolvedTask && <TaskDetail task={resolvedTask as any} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TriggerDetail } from "~/components/run/TriggerDetail";
|
||||
import { TriggerDetailsPresenter } from "~/presenters/TriggerDetailsPresenter.server";
|
||||
import { TriggerSourceRunParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { runParam } = TriggerSourceRunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TriggerDetailsPresenter();
|
||||
const trigger = await presenter.call(runParam);
|
||||
|
||||
if (!trigger) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { trigger } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<TriggerDetail
|
||||
trigger={trigger}
|
||||
event={{ icon: "mail-fast", title: "Deliver Webhook" }}
|
||||
properties={[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEventSource } from "remix-utils/sse/react";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { RunOverview } from "~/components/run/RunOverview";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { RunPresenter } from "~/presenters/RunPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceRunParamsSchema,
|
||||
projectWebhookTriggersPath,
|
||||
trimTrailingSlash,
|
||||
webhookDeliveryPath,
|
||||
webhookTriggerDeliveryRunPath,
|
||||
webhookTriggerDeliveryRunsParentPath,
|
||||
webhookTriggerPath,
|
||||
webhookTriggerRunStreamingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { runParam, triggerParam } = TriggerSourceRunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new RunPresenter();
|
||||
const run = await presenter.call({
|
||||
userId,
|
||||
id: runParam,
|
||||
});
|
||||
|
||||
const trigger = await prisma.webhook.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
integration: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
definitionId: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: triggerParam,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run || !trigger) {
|
||||
throw new Response(null, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
run,
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match, matches) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
if (!data) return null;
|
||||
|
||||
const org = useOrganization(matches);
|
||||
const project = useProject(matches);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={projectWebhookTriggersPath(org, project)} title="Webhook Triggers" />
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink
|
||||
to={webhookTriggerPath(org, project, { id: data.trigger.id })}
|
||||
title={`${data.trigger.integration.title}: ${data.trigger.integration.slug}`}
|
||||
/>
|
||||
<BreadcrumbIcon />
|
||||
<BreadcrumbLink to={webhookDeliveryPath(org, project, { id: data.trigger.id })} title="Deliveries" />
|
||||
<BreadcrumbIcon />
|
||||
{data && data.run && (
|
||||
<BreadcrumbLink
|
||||
to={trimTrailingSlash(match.pathname)}
|
||||
title={`Run #${data.run.number}`}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { run, trigger } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const events = useEventSource(
|
||||
webhookTriggerRunStreamingPath(organization, project, trigger, run),
|
||||
{
|
||||
event: "message",
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (events !== null) {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<RunOverview
|
||||
run={run}
|
||||
trigger={{ icon: "mail-fast", title: "Deliver Webhook" }}
|
||||
showRerun={false}
|
||||
paths={{
|
||||
back: webhookDeliveryPath(organization, project, { id: trigger.id }),
|
||||
run: webhookTriggerDeliveryRunPath(organization, project, { id: trigger.id }, run),
|
||||
runsPath: webhookTriggerDeliveryRunsParentPath(organization, project, {
|
||||
id: trigger.id,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import type { ActionFunction, LoaderFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -15,6 +16,8 @@ import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
@@ -24,6 +27,7 @@ import { projectPath, rootPath } from "~/utils/pathBuilder";
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
projectName: z.string().min(3).max(50),
|
||||
companySize: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
@@ -51,6 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
title: submission.value.orgName,
|
||||
userId,
|
||||
projectName: submission.value.projectName,
|
||||
companySize: submission.value.companySize ?? null,
|
||||
});
|
||||
|
||||
const project = organization.projects[0];
|
||||
@@ -69,6 +74,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
export default function NewOrganizationPage() {
|
||||
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
const [form, { orgName, projectName }] = useForm({
|
||||
id: "create-organization",
|
||||
@@ -77,52 +83,87 @@ export default function NewOrganizationPage() {
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<div>
|
||||
<FormTitle LeadingIcon="organization" title="Create a new Organization" />
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={orgName.id}>Organization name</Label>
|
||||
<Input
|
||||
{...conform.input(orgName, { type: "text" })}
|
||||
placeholder="Your Organization name"
|
||||
icon="organization"
|
||||
/>
|
||||
<Hint>E.g. your company name or your workspace name.</Hint>
|
||||
<FormError id={orgName.errorId}>{orgName.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
placeholder="Your Project name"
|
||||
icon="folder"
|
||||
/>
|
||||
<Hint>Your Jobs will live inside this Project.</Hint>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
hasOrganizations ? (
|
||||
<LinkButton to={rootPath()} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : null
|
||||
}
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
<FormTitle LeadingIcon="organization" title="Create an Organization" />
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={orgName.id}>Organization name</Label>
|
||||
<Input
|
||||
{...conform.input(orgName, { type: "text" })}
|
||||
placeholder="Your Organization name"
|
||||
icon="organization"
|
||||
autoFocus
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
<Hint>E.g. your company name or your workspace name.</Hint>
|
||||
<FormError id={orgName.errorId}>{orgName.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
placeholder="Your Project name"
|
||||
icon="folder"
|
||||
/>
|
||||
<Hint>Your Jobs will live inside this Project.</Hint>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
{isManagedCloud && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Number of employees</Label>
|
||||
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
|
||||
<RadioGroupItem
|
||||
id="employees-1-5"
|
||||
label="1-5"
|
||||
value={"1-5"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-6-49"
|
||||
label="6-49"
|
||||
value={"6-49"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-50-99"
|
||||
label="50-99"
|
||||
value={"50-99"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-100+"
|
||||
label="100+"
|
||||
value={"100+"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
hasOrganizations ? (
|
||||
<LinkButton to={rootPath()} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
|
||||
import { GetEvent } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runOriginalStatus } from "~/models/jobRun.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
|
||||
@@ -49,7 +50,7 @@ function toJSON(eventRecord: FoundEventRecord): GetEvent {
|
||||
updatedAt: eventRecord.updatedAt,
|
||||
runs: eventRecord.runs.map((run) => ({
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
status: runOriginalStatus(run.status),
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
})),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
|
||||
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { runOriginalStatus } from "~/models/jobRun.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
@@ -66,7 +67,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
json({
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
status: runOriginalStatus(run.status),
|
||||
output: run.output,
|
||||
},
|
||||
statuses: parsedStatuses,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { runOriginalStatus } from "~/models/jobRun.server";
|
||||
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
@@ -79,7 +80,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
request,
|
||||
json({
|
||||
id: jobRun.id,
|
||||
status: jobRun.status,
|
||||
status: runOriginalStatus(jobRun.status),
|
||||
startedAt: jobRun.startedAt,
|
||||
updatedAt: jobRun.updatedAt,
|
||||
completedAt: jobRun.completedAt,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { KeyValueStore } from "~/services/store/keyValueStore.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
const MAX_BODY_BYTE_LENGTH = 256 * 1024;
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
logger.info("Key-value store action", { url: request.url });
|
||||
|
||||
const ActionMethodSchema = z.enum(["DELETE", "PUT"]);
|
||||
|
||||
const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
|
||||
|
||||
if (!parsedMethod.success) {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const store = new KeyValueStore(authenticatedEnv);
|
||||
|
||||
const decodedKey = decodeURIComponent(parsedParams.data.key);
|
||||
|
||||
try {
|
||||
switch (parsedMethod.data) {
|
||||
case "DELETE": {
|
||||
const deleted = await store.delete(decodedKey);
|
||||
|
||||
return json({ action: "DELETE", key: decodedKey, deleted });
|
||||
}
|
||||
case "PUT": {
|
||||
const value = await request.text();
|
||||
|
||||
const serializedValueBytes = value.length;
|
||||
|
||||
if (serializedValueBytes > MAX_BODY_BYTE_LENGTH) {
|
||||
logger.info("Max request body size exceeded", { serializedValueBytes });
|
||||
|
||||
return json(
|
||||
{ error: `Max request body size exceeded: ${MAX_BODY_BYTE_LENGTH} bytes` },
|
||||
{ status: 413 }
|
||||
);
|
||||
}
|
||||
|
||||
const setValue = await store.set(decodedKey, value);
|
||||
|
||||
return json({ action: "SET", key: decodedKey, value: setValue });
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(parsedMethod.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error peforming key-value store action", {
|
||||
method: parsedMethod.data,
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
logger.info("Key-value store loader", { url: request.url });
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ActionMethodSchema = z.enum(["GET", "HEAD"]);
|
||||
|
||||
const parsedMethod = ActionMethodSchema.safeParse(request.method.toUpperCase());
|
||||
|
||||
if (!parsedMethod.success) {
|
||||
return json({ error: "Method Not Allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const store = new KeyValueStore(authenticatedEnv);
|
||||
|
||||
const { key } = parsedParams.data;
|
||||
|
||||
try {
|
||||
switch (parsedMethod.data) {
|
||||
case "GET": {
|
||||
const value = await store.get(key);
|
||||
|
||||
return json({ action: "GET", key, value });
|
||||
}
|
||||
case "HEAD": {
|
||||
const has = await store.has(key);
|
||||
|
||||
if (!has) {
|
||||
return new Response("Key not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response("Key found", { status: 200 });
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(parsedMethod.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error peforming key-value store action", {
|
||||
method: parsedMethod.data,
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { UpdateWebhookBodySchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { UpdateWebhookService } from "~/services/sources/updateWebhook.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
logger.info("Updating webhook", { url: request.url });
|
||||
|
||||
// Ensure this is a POST request
|
||||
if (request.method.toUpperCase() !== "PUT") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
logger.info("Invalid params", { params });
|
||||
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
logger.info("Invalid or missing api key", { url: request.url });
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
// Now parse the request body
|
||||
const anyBody = await request.json();
|
||||
|
||||
const body = UpdateWebhookBodySchema.safeParse(anyBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new UpdateWebhookService();
|
||||
|
||||
try {
|
||||
const source = await service.call({
|
||||
environment: authenticatedEnv,
|
||||
payload: body.data,
|
||||
key: parsedParams.data.key,
|
||||
});
|
||||
|
||||
return json(source);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Error updating webhook", {
|
||||
url: request.url,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({ error: "Something went wrong" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { updateUser } from "~/models/user.server";
|
||||
@@ -56,6 +57,7 @@ function createSchema(
|
||||
}
|
||||
}),
|
||||
confirmEmail: z.string(),
|
||||
referralSource: z.string().optional(),
|
||||
})
|
||||
.refine((value) => value.email === value.confirmEmail, {
|
||||
message: "Emails must match",
|
||||
@@ -98,6 +100,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
id: userId,
|
||||
name: submission.value.name,
|
||||
email: submission.value.email,
|
||||
referralSource: submission.value.referralSource,
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(rootPath(), request, "Your details have been updated.");
|
||||
@@ -119,111 +122,121 @@ export default function Page() {
|
||||
const user = useUser();
|
||||
const lastSubmission = useActionData();
|
||||
const [enteredEmail, setEnteredEmail] = useState<string>(user.email ?? "");
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
const [form, { name, email, confirmEmail }] = useForm({
|
||||
const [form, { name, email, confirmEmail, referralSource }] = useForm({
|
||||
id: "confirm-basic-details",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: createSchema() });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const shouldShowConfirm = user.email !== enteredEmail || user.email === "";
|
||||
|
||||
return (
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<MainCenteredContainer>
|
||||
<div>
|
||||
<Form method="post" {...form.props}>
|
||||
<FormTitle
|
||||
title="Welcome to Trigger.dev"
|
||||
LeadingIcon={
|
||||
<MotionHand
|
||||
style={{
|
||||
originY: 0.75,
|
||||
}}
|
||||
initial={{
|
||||
rotate: 0,
|
||||
}}
|
||||
animate={{
|
||||
rotate: [0, -20, 0, 20, 0, -20, 0, 20, 0],
|
||||
}}
|
||||
transition={{
|
||||
delay: 1,
|
||||
duration: 1,
|
||||
repeatDelay: 5,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description="We just need you to confirm a couple of details, it'll only take a minute."
|
||||
/>
|
||||
<Fieldset>
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
<Form method="post" {...form.props}>
|
||||
<FormTitle
|
||||
title="Welcome to Trigger.dev"
|
||||
LeadingIcon={
|
||||
<MotionHand
|
||||
style={{
|
||||
originY: 0.75,
|
||||
}}
|
||||
initial={{
|
||||
rotate: 0,
|
||||
}}
|
||||
animate={{
|
||||
rotate: [0, -20, 0, 20, 0, -20, 0, 20, 0],
|
||||
}}
|
||||
transition={{
|
||||
delay: 1,
|
||||
duration: 1,
|
||||
repeatDelay: 5,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description="We just need you to confirm a couple of details, it'll only take a minute."
|
||||
/>
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={name.id}>Full name</Label>
|
||||
<Input
|
||||
{...conform.input(name, { type: "text" })}
|
||||
defaultValue={user.name ?? ""}
|
||||
placeholder="Your full name"
|
||||
icon="user"
|
||||
autoFocus
|
||||
/>
|
||||
<Hint>Your team will see this name and we'll use it if we need to contact you.</Hint>
|
||||
<FormError id={name.errorId}>{name.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={email.id}>Email</Label>
|
||||
<Input
|
||||
{...conform.input(email, { type: "email" })}
|
||||
defaultValue={enteredEmail}
|
||||
onChange={(e) => {
|
||||
setEnteredEmail(e.target.value);
|
||||
}}
|
||||
placeholder="Your email address"
|
||||
icon="envelope"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!shouldShowConfirm && (
|
||||
<Hint>
|
||||
Check this is the email you'd like associated with your Trigger.dev account.
|
||||
</Hint>
|
||||
)}
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
{shouldShowConfirm ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={name.id}>Full name</Label>
|
||||
<Label htmlFor={confirmEmail.id}>Confirm email</Label>
|
||||
<Input
|
||||
{...conform.input(name, { type: "text" })}
|
||||
defaultValue={user.name ?? ""}
|
||||
placeholder="Your full name"
|
||||
icon="user"
|
||||
autoFocus={Boolean(name.initialError)}
|
||||
/>
|
||||
<Hint>Your team will see this name and we'll use it if we contact you.</Hint>
|
||||
<FormError id={name.errorId}>{name.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={email.id}>Email</Label>
|
||||
<Input
|
||||
{...conform.input(email, { type: "email" })}
|
||||
defaultValue={enteredEmail}
|
||||
onChange={(e) => {
|
||||
setEnteredEmail(e.target.value);
|
||||
}}
|
||||
placeholder="Your email address"
|
||||
{...conform.input(confirmEmail, { type: "email" })}
|
||||
placeholder="Your email, again"
|
||||
icon="envelope"
|
||||
autoFocus={Boolean(email.initialError)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!shouldShowConfirm && (
|
||||
<Hint>
|
||||
Check this is the email you'd like associated with your Trigger.dev account.
|
||||
</Hint>
|
||||
)}
|
||||
<FormError id={email.errorId}>{email.error}</FormError>
|
||||
<Hint>
|
||||
Check this is the email you'd like associated with your Trigger.dev account.
|
||||
</Hint>
|
||||
<FormError id={confirmEmail.errorId}>{confirmEmail.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<>
|
||||
<input {...conform.input(confirmEmail, { type: "hidden" })} value={user.email} />
|
||||
</>
|
||||
)}
|
||||
{isManagedCloud && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={confirmEmail.id}>How did you hear about us?</Label>
|
||||
<Input
|
||||
{...conform.input(referralSource, { type: "text" })}
|
||||
placeholder="Google, Twitter…?"
|
||||
icon="heart"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
{shouldShowConfirm ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={confirmEmail.id}>Confirm email</Label>
|
||||
<Input
|
||||
{...conform.input(confirmEmail, { type: "email" })}
|
||||
placeholder="Your email, again"
|
||||
icon="envelope"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Hint>
|
||||
Check this is the email you'd like associated with your Trigger.dev account.
|
||||
</Hint>
|
||||
<FormError id={confirmEmail.errorId}>{confirmEmail.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<>
|
||||
<input {...conform.input(confirmEmail, { type: "hidden" })} value={user.email} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon={"arrow-right"}>
|
||||
Continue
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon={"arrow-right"}>
|
||||
Continue
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import type { DataFunctionArgs, LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { ServerRuntimeMetaArgs, ServerRuntimeMetaDescriptor } from "@remix-run/server-runtime";
|
||||
import { getMatchesData, metaV1 } from "@remix-run/v1-meta";
|
||||
import {
|
||||
TypedJsonResponse,
|
||||
TypedMetaFunction,
|
||||
UseDataFunctionReturn,
|
||||
redirect,
|
||||
typedjson,
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
@@ -67,61 +64,59 @@ export default function LoginPage() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<MainCenteredContainer>
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<a href="https://trigger.dev">
|
||||
<LogoIcon className="mb-4 h-16 w-16" />
|
||||
</a>
|
||||
<FormTitle divide={false} title="Welcome to Trigger.dev" className="mb-2 pb-0" />
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{data.showGithubAuth && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
>
|
||||
<NamedIcon name={"github"} className={"mr-1.5 h-4 w-4"} />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
)}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
variant="secondary/large"
|
||||
<LoginPageLayout>
|
||||
<Form
|
||||
action={`/auth/github${data.redirectTo ? `?redirectTo=${data.redirectTo}` : ""}`}
|
||||
method="post"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<Header1 className="pb-4 font-normal sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6">
|
||||
Create an account or login
|
||||
</Paragraph>
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{data.showGithubAuth && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with email"
|
||||
data-action="continue with github"
|
||||
>
|
||||
<NamedIcon
|
||||
name={"envelope"}
|
||||
className={"mr-1.5 h-4 w-4 text-dimmed transition group-hover:text-bright"}
|
||||
/>
|
||||
Continue with Email
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>
|
||||
{" "}and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>
|
||||
{" "}policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
<NamedIcon name={"github"} className={"mr-2 h-6 w-6"} />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
)}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with email"
|
||||
>
|
||||
<NamedIcon
|
||||
name={"envelope"}
|
||||
className={"mr-1.5 h-4 w-4 text-dimmed transition group-hover:text-bright"}
|
||||
/>
|
||||
Continue with Email
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>
|
||||
{" "}and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>
|
||||
{" "}policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
</div>
|
||||
</Form>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { getMatchesData, metaV1 } from "@remix-run/v1-meta";
|
||||
import {
|
||||
TypedMetaFunction,
|
||||
UseDataFunctionReturn,
|
||||
@@ -8,25 +10,21 @@ import {
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import type { LoaderType as RootLoader } from "~/root";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
|
||||
import magicLinkIcon from "./login.magic.svg";
|
||||
import type { LoaderType as RootLoader } from "~/root";
|
||||
import { appEnvTitleTag } from "~/utils";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { getMatchesData, metaV1 } from "@remix-run/v1-meta";
|
||||
|
||||
export const meta: TypedMetaFunction<typeof loader> = (args) => {
|
||||
const matchesData = getMatchesData(args) as { root: UseDataFunctionReturn<RootLoader> };
|
||||
@@ -102,127 +100,109 @@ export default function LoginMagicLinkPage() {
|
||||
navigate.formData?.get("action") === "send";
|
||||
|
||||
return (
|
||||
<AppContainer showBackgroundGradient={true}>
|
||||
<MainCenteredContainer>
|
||||
<Form method="post">
|
||||
<div className="flex flex-col items-center">
|
||||
<a href="https://trigger.dev">
|
||||
<LogoIcon className="mb-4 h-16 w-16" />
|
||||
</a>
|
||||
|
||||
{magicLinkSent ? (
|
||||
<>
|
||||
<FormTitle divide={false} title="We've sent you a magic link!" />
|
||||
<img src={magicLinkIcon} className="mb-4 h-12 w-12" />
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<Paragraph className="mb-6 text-center">
|
||||
We sent you an email which contains a magic link that will log you in to your
|
||||
account.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
cancelButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="reset"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon="arrow-left"
|
||||
leadingIconClassName="text-dimmed group-hover:text-bright transition"
|
||||
data-action="re-enter email"
|
||||
>
|
||||
Re-enter email
|
||||
</Button>
|
||||
}
|
||||
confirmButton={
|
||||
<LinkButton
|
||||
to="/login"
|
||||
variant="tertiary/small"
|
||||
data-action="log in using another option"
|
||||
>
|
||||
Log in using another option
|
||||
</LinkButton>
|
||||
}
|
||||
<LoginPageLayout>
|
||||
<Form method="post">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{magicLinkSent ? (
|
||||
<>
|
||||
<Header1 className="pb-6 text-center text-xl font-normal leading-7 md:text-xl lg:text-2xl">
|
||||
We've sent you a magic link!
|
||||
</Header1>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InboxArrowDownIcon className="mb-4 h-12 w-12 text-primary" />
|
||||
<Paragraph className="mb-6 text-center">
|
||||
We sent you an email which contains a magic link that will log you in to your
|
||||
account.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
cancelButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="reset"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon="arrow-left"
|
||||
leadingIconClassName="text-dimmed group-hover:text-bright transition"
|
||||
data-action="re-enter email"
|
||||
>
|
||||
Re-enter email
|
||||
</Button>
|
||||
}
|
||||
confirmButton={
|
||||
<LinkButton
|
||||
to="/login"
|
||||
variant="tertiary/small"
|
||||
data-action="log in using another option"
|
||||
>
|
||||
Log in using another option
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header1 className="pb-4 font-normal sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
Welcome
|
||||
</Header1>
|
||||
<Paragraph variant="base" className="mb-6 text-center">
|
||||
Create an account or login using email
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputGroup>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
spellCheck={false}
|
||||
placeholder="Email Address"
|
||||
variant="large"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Fieldset>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormTitle divide={false} title="Welcome to Trigger.dev" className="mb-2 pb-0" />
|
||||
<Paragraph variant="small" className="mb-4 text-center">
|
||||
Create an account or login using your email
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputGroup>
|
||||
<Label>Your email address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
spellCheck={false}
|
||||
placeholder="Email Address"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
</InputGroup>
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="send"
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
data-action="send a magic link"
|
||||
>
|
||||
<NamedIcon
|
||||
name={isLoading ? "spinner-white" : "envelope"}
|
||||
className={"mr-1.5 h-4 w-4 text-white transition group-hover:text-bright"}
|
||||
/>
|
||||
{isLoading ? "Sending…" : "Send a magic link"}
|
||||
</Button>
|
||||
{magicLinkError && <FormError>{magicLinkError}</FormError>}
|
||||
</Fieldset>
|
||||
<Paragraph variant="extra-small" className="my-4 text-center">
|
||||
By logging in with your email you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>{" "}
|
||||
and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>{" "}
|
||||
policy.
|
||||
</Paragraph>
|
||||
|
||||
<LinkButton
|
||||
to="/login"
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon={"arrow-left"}
|
||||
leadingIconClassName="text-dimmed group-hover:text-bright transition"
|
||||
data-action="all login options"
|
||||
<Button
|
||||
name="action"
|
||||
value="send"
|
||||
type="submit"
|
||||
variant="primary/large"
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
data-action="send a magic link"
|
||||
>
|
||||
All login options
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 rounded border border-border px-6 py-4">
|
||||
<Paragraph variant="small" className="mb-2 text-center">
|
||||
Having login issues?
|
||||
</Paragraph>
|
||||
<Paragraph variant="extra-small" className="text-center">
|
||||
Ensure the Magic Link email isn't in your spam folder. If the problem persists,{" "}
|
||||
<TextLink href="mailto:help@trigger.dev" target="_blank">
|
||||
drop us an email
|
||||
</TextLink>{" "}
|
||||
or let us know on{" "}
|
||||
<TextLink href="https://trigger.dev/discord" target="_blank">
|
||||
Discord
|
||||
<NamedIcon
|
||||
name={isLoading ? "spinner-white" : "envelope"}
|
||||
className={"mr-1.5 h-4 w-4 text-white transition group-hover:text-bright"}
|
||||
/>
|
||||
{isLoading ? "Sending…" : "Send a magic link"}
|
||||
</Button>
|
||||
{magicLinkError && <FormError>{magicLinkError}</FormError>}
|
||||
</Fieldset>
|
||||
<Paragraph variant="extra-small" className="mb-4 mt-6 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>
|
||||
.
|
||||
{" "}and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>
|
||||
{" "}policy.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
|
||||
<LinkButton
|
||||
to="/login"
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon={"arrow-left"}
|
||||
leadingIconClassName="text-dimmed group-hover:text-bright transition"
|
||||
data-action="all login options"
|
||||
>
|
||||
All login options
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
ComponentDividerSpacingSize,
|
||||
ComponentSpacerSize,
|
||||
ComponentTextColor,
|
||||
ComponentTextSize,
|
||||
PlainClient,
|
||||
} from "@team-plain/typescript-sdk";
|
||||
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { inspect } from "util";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
@@ -68,6 +62,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
onCreate: {
|
||||
externalId: user.id,
|
||||
fullName: user.name ?? "",
|
||||
// TODO - Optional: set 'first name' on user
|
||||
// shortName: ''
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
@@ -76,6 +72,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
onUpdate: {
|
||||
externalId: { value: user.id },
|
||||
fullName: { value: user.name ?? "" },
|
||||
// TODO - see above
|
||||
// shortName: { value: "" },
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
@@ -96,63 +94,50 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
|
||||
const upsertTimelineEntryRes = await client.upsertCustomTimelineEntry({
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
const createThreadRes = await client.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
},
|
||||
title,
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text: `New ${title} reported by ${user.name} (${user.email})`,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentDivider: {
|
||||
dividerSpacingSize: ComponentDividerSpacingSize.M,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
textSize: ComponentTextSize.S,
|
||||
textColor: ComponentTextColor.Muted,
|
||||
text: "Page",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: submission.value.path,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentSpacer: {
|
||||
spacerSize: ComponentSpacerSize.M,
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
textSize: ComponentTextSize.S,
|
||||
textColor: ComponentTextColor.Muted,
|
||||
text: "Message",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentText: {
|
||||
text: submission.value.message,
|
||||
},
|
||||
},
|
||||
uiComponent.text({
|
||||
text: `New ${title} reported by ${user.name} (${user.email})`,
|
||||
}),
|
||||
uiComponent.divider({ spacingSize: "M" }),
|
||||
uiComponent.text({
|
||||
size: "S",
|
||||
color: "MUTED",
|
||||
text: "Page",
|
||||
}),
|
||||
uiComponent.text({
|
||||
text: submission.value.path,
|
||||
}),
|
||||
uiComponent.spacer({ size: "M" }),
|
||||
uiComponent.text({
|
||||
size: "S",
|
||||
color: "MUTED",
|
||||
text: "Message",
|
||||
}),
|
||||
uiComponent.text({
|
||||
text: submission.value.message,
|
||||
}),
|
||||
],
|
||||
changeCustomerStatusToActive: true,
|
||||
sendCustomTimelineEntryCreatedNotification: true,
|
||||
// TODO: Optional: set labels on threads here on creation
|
||||
// labelTypeIds: [],
|
||||
|
||||
// TODO: Optional: set the priority (0 is urgent, 3 is low)
|
||||
// priority: 0,
|
||||
});
|
||||
|
||||
if (upsertTimelineEntryRes.error) {
|
||||
if (createThreadRes.error) {
|
||||
console.error(
|
||||
inspect(upsertTimelineEntryRes.error, {
|
||||
inspect(createThreadRes.error, {
|
||||
showHidden: false,
|
||||
depth: null,
|
||||
colors: true,
|
||||
})
|
||||
);
|
||||
submission.error.message = upsertTimelineEntryRes.error.message;
|
||||
submission.error.message = createThreadRes.error.message;
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Logger } from "@trigger.dev/core-backend";
|
||||
import type { PoolClient } from "pg";
|
||||
import { z } from "zod";
|
||||
import { Logger } from "@trigger.dev/core";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { NotificationCatalog, NotificationChannel, notificationCatalog } from "./types";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
import { NotificationCatalog, NotificationChannel, notificationCatalog } from "./types";
|
||||
|
||||
export class PgListenService {
|
||||
#poolClient: PoolClient;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
RunNotification,
|
||||
ValidateResponse,
|
||||
ValidateResponseSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "@trigger.dev/core";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { safeBodyFromResponse, safeParseBodyFromResponse } from "~/utils/json";
|
||||
@@ -122,6 +123,7 @@ export class EndpointApi {
|
||||
"x-trigger-api-key": this.apiKey,
|
||||
"x-trigger-action": "INDEX_ENDPOINT",
|
||||
},
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -253,6 +255,45 @@ export class EndpointApi {
|
||||
return HttpSourceResponseSchema.parse(anyBody);
|
||||
}
|
||||
|
||||
async deliverWebhookRequest(options: {
|
||||
key: string;
|
||||
secret: string;
|
||||
params: any;
|
||||
request: HttpSourceRequest;
|
||||
}) {
|
||||
const response = await safeFetch(this.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"x-trigger-api-key": this.apiKey,
|
||||
"x-trigger-action": "DELIVER_WEBHOOK_REQUEST",
|
||||
"x-ts-key": options.key,
|
||||
"x-ts-secret": options.secret,
|
||||
"x-ts-params": JSON.stringify(options.params ?? {}),
|
||||
"x-ts-http-url": options.request.url,
|
||||
"x-ts-http-method": options.request.method,
|
||||
"x-ts-http-headers": JSON.stringify(options.request.headers),
|
||||
},
|
||||
body: options.request.rawBody,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
throw new Error(`Could not connect to endpoint ${this.url}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not connect to endpoint ${this.url}. Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const anyBody = await response.json();
|
||||
|
||||
logger.debug("deliverWebhookRequest() response from endpoint", {
|
||||
body: anyBody,
|
||||
});
|
||||
|
||||
return WebhookDeliveryResponseSchema.parse(anyBody);
|
||||
}
|
||||
|
||||
async deliverHttpEndpointRequestForResponse(options: {
|
||||
key: string;
|
||||
secret: string;
|
||||
@@ -404,6 +445,7 @@ function addStandardRequestOptions(options: RequestInit) {
|
||||
...options.headers,
|
||||
"user-agent": "triggerdotdev-server/2.0.0",
|
||||
"x-trigger-version": API_VERSIONS.LAZY_LOADED_CACHED_TASKS,
|
||||
accept: "application/json",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { EndpointIndexSource } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { findEndpoint } from "~/models/endpoint.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { RegisterJobService } from "../jobs/registerJob.server";
|
||||
import { logger } from "../logger.server";
|
||||
@@ -14,6 +12,8 @@ import { safeBodyFromResponse } from "~/utils/json";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { IndexEndpointStats } from "@trigger.dev/core";
|
||||
import { RegisterHttpEndpointService } from "../triggers/registerHttpEndpoint.server";
|
||||
import { RegisterWebhookService } from "../triggers/registerWebhook.server";
|
||||
import { EndpointIndex } from "@trigger.dev/database";
|
||||
|
||||
export class PerformEndpointIndexService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -24,12 +24,13 @@ export class PerformEndpointIndexService {
|
||||
#registerDynamicTriggerService = new RegisterDynamicTriggerService();
|
||||
#registerDynamicScheduleService = new RegisterDynamicScheduleService();
|
||||
#registerHttpEndpointService = new RegisterHttpEndpointService();
|
||||
#registerWebhookService = new RegisterWebhookService();
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
public async call(id: string, redirectCount = 0): Promise<EndpointIndex> {
|
||||
const endpointIndex = await this.#prismaClient.endpointIndex.update({
|
||||
where: {
|
||||
id,
|
||||
@@ -66,6 +67,39 @@ export class PerformEndpointIndexService {
|
||||
});
|
||||
}
|
||||
|
||||
if (isRedirect(response.status)) {
|
||||
// Update the endpoint URL with the response.headers.location
|
||||
logger.debug("Endpoint is redirecting", {
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
});
|
||||
|
||||
const location = response.headers.get("location");
|
||||
|
||||
if (!location) {
|
||||
return updateEndpointIndexWithError(this.#prismaClient, id, {
|
||||
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting but no location header is present`,
|
||||
});
|
||||
}
|
||||
|
||||
if (redirectCount > 5) {
|
||||
return updateEndpointIndexWithError(this.#prismaClient, id, {
|
||||
message: `Endpoint ${endpointIndex.endpoint.url} is redirecting too many times`,
|
||||
});
|
||||
}
|
||||
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id: endpointIndex.endpoint.id,
|
||||
},
|
||||
data: {
|
||||
url: location,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-run the endpoint index
|
||||
return await this.call(id, redirectCount + 1);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
const body = await safeBodyFromResponse(response, errorParser);
|
||||
|
||||
@@ -128,7 +162,8 @@ export class PerformEndpointIndexService {
|
||||
});
|
||||
}
|
||||
|
||||
const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints } = bodyResult.data;
|
||||
const { jobs, sources, dynamicTriggers, dynamicSchedules, httpEndpoints, webhooks } =
|
||||
bodyResult.data;
|
||||
const { "trigger-version": triggerVersion, "trigger-sdk-version": triggerSdkVersion } =
|
||||
headerResult.data;
|
||||
const { endpoint } = endpointIndex;
|
||||
@@ -151,6 +186,7 @@ export class PerformEndpointIndexService {
|
||||
const indexStats: IndexEndpointStats = {
|
||||
jobs: 0,
|
||||
sources: 0,
|
||||
webhooks: 0,
|
||||
dynamicTriggers: 0,
|
||||
dynamicSchedules: 0,
|
||||
disabledJobs: 0,
|
||||
@@ -318,6 +354,21 @@ export class PerformEndpointIndexService {
|
||||
}
|
||||
}
|
||||
|
||||
if (webhooks) {
|
||||
for (const webhook of webhooks) {
|
||||
try {
|
||||
await this.#registerWebhookService.call(endpoint, webhook);
|
||||
indexStats.webhooks = indexStats.webhooks ?? 0 + 1;
|
||||
} catch (error) {
|
||||
logger.error("Failed to register webhook", {
|
||||
endpointId: endpoint.id,
|
||||
webhook,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Endpoint indexing complete", {
|
||||
endpointId: endpoint.id,
|
||||
indexStats,
|
||||
@@ -336,6 +387,7 @@ export class PerformEndpointIndexService {
|
||||
data: {
|
||||
jobs,
|
||||
sources,
|
||||
webhooks,
|
||||
dynamicTriggers,
|
||||
dynamicSchedules,
|
||||
httpEndpoints,
|
||||
@@ -360,3 +412,10 @@ async function updateEndpointIndexWithError(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const redirectStatus = [301, 302, 303, 307, 308];
|
||||
const redirectStatusSet = new Set(redirectStatus);
|
||||
|
||||
function isRedirect(status: number) {
|
||||
return redirectStatusSet.has(status);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MAX_RUN_CHUNK_EXECUTION_LIMIT, RESPONSE_TIMEOUT_STATUS_CODES } from "~/consts";
|
||||
import { MAX_RUN_CHUNK_EXECUTION_LIMIT } from "~/consts";
|
||||
import { prisma, PrismaClient } from "~/db.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { logger } from "../logger.server";
|
||||
@@ -46,8 +46,10 @@ export class ProbeEndpointService {
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
|
||||
// If the response is a 200, or it was a timeout, we can assume the endpoint is up and update the runChunkExecutionLimit
|
||||
if (response.status === 200 || detectResponseIsTimeout(response)) {
|
||||
if (response.status === 200 || detectResponseIsTimeout(rawBody, response)) {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
where: {
|
||||
id,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type CreateExecutionEventInput = {
|
||||
eventTime: Date;
|
||||
eventType: "start" | "finish";
|
||||
drift?: number;
|
||||
concurrencyLimitGroupId?: string | null;
|
||||
};
|
||||
|
||||
export class CreateExecutionEventService {
|
||||
@@ -25,7 +26,8 @@ export class CreateExecutionEventService {
|
||||
"run_id",
|
||||
"event_time",
|
||||
"event_type",
|
||||
"drift_amount_in_ms"
|
||||
"drift_amount_in_ms",
|
||||
"concurrency_limit_group_id"
|
||||
) VALUES (
|
||||
${input.organizationId},
|
||||
${input.projectId},
|
||||
@@ -34,7 +36,8 @@ export class CreateExecutionEventService {
|
||||
${input.runId},
|
||||
${input.eventTime},
|
||||
${input.eventType === "start" ? 1 : -1},
|
||||
${input.drift}
|
||||
${input.drift},
|
||||
${input.concurrencyLimitGroupId}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,15 @@ export const apisList = [
|
||||
{
|
||||
identifier: "clerk",
|
||||
name: "Clerk",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a Clerk webhook.",
|
||||
slug: "clerk-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/clerk-http-endpoint.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
identifier: "clickup",
|
||||
@@ -282,6 +291,13 @@ export const apisList = [
|
||||
identifier: "hubspot",
|
||||
name: "HubSpot",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a HubSpot webhook.",
|
||||
slug: "hubspot-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/hubspot-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Create a contact in HubSpot.",
|
||||
slug: "create-contact-in-hubspot",
|
||||
@@ -294,6 +310,13 @@ export const apisList = [
|
||||
identifier: "huggingface",
|
||||
name: "Hugging Face",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a Hugging Face webhook.",
|
||||
slug: "hugging-face-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/hugging-face-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Text classification with Hugging Face.",
|
||||
slug: "text-classification-with-hugging-face",
|
||||
@@ -393,6 +416,13 @@ export const apisList = [
|
||||
identifier: "mailgun",
|
||||
name: "Mailgun",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a Mailgun webhook.",
|
||||
slug: "mailgun-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/mailgun-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Send an email with Mailgun.",
|
||||
slug: "send-email-with-mailgun",
|
||||
@@ -429,6 +459,13 @@ export const apisList = [
|
||||
identifier: "novu",
|
||||
name: "Novu",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a Novu webhook.",
|
||||
slug: "novu-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/novu-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Create a new subscriber in Novu",
|
||||
slug: "create-new-subscriber-in-novu",
|
||||
@@ -757,6 +794,13 @@ export const apisList = [
|
||||
identifier: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
examples: [
|
||||
{
|
||||
title: "A job that is triggered by a WhatsApp webhook.",
|
||||
slug: "whatsapp-http-endpoint",
|
||||
version: "1.0.0",
|
||||
codeUrl:
|
||||
"https://raw.githubusercontent.com/triggerdotdev/api-reference/main/src/whatsapp-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Send a message to a WhatsApp number",
|
||||
slug: "whatapp-send-message",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { plain } from "./integrations/plain";
|
||||
import { replicate } from "./integrations/replicate";
|
||||
import { resend } from "./integrations/resend";
|
||||
import { sendgrid } from "./integrations/sendgrid";
|
||||
import { shopify } from "./integrations/shopify";
|
||||
import { slack } from "./integrations/slack";
|
||||
import { stripe } from "./integrations/stripe";
|
||||
import { supabase, supabaseManagement } from "./integrations/supabase";
|
||||
@@ -40,6 +41,7 @@ export const integrationCatalog = new IntegrationCatalog({
|
||||
plain,
|
||||
replicate,
|
||||
resend,
|
||||
shopify,
|
||||
slack,
|
||||
stripe,
|
||||
supabaseManagement,
|
||||
|
||||
@@ -7,7 +7,7 @@ function usageSample(hasApiKey: boolean): HelpSample {
|
||||
import { Linear } from "@trigger.dev/linear";
|
||||
|
||||
const linear = new Linear({
|
||||
id: "__SLUG__",${hasApiKey ? ",\n apiKey: process.env.LINEAR_API_KEY!" : ""}
|
||||
id: "__SLUG__",${hasApiKey ? "\n apiKey: process.env.LINEAR_API_KEY!," : ""}
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
|
||||
@@ -9,7 +9,7 @@ function usageSample(hasApiKey: boolean): HelpSample {
|
||||
import { Replicate } from "@trigger.dev/replicate";
|
||||
|
||||
const replicate = new Replicate({
|
||||
id: "__SLUG__",${hasApiKey ? `,\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!` : ""}
|
||||
id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.REPLICATE_API_KEY!,` : ""}
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { HelpSample, Integration } from "../types";
|
||||
|
||||
function usageSample(hasApiKey: boolean): HelpSample {
|
||||
const apiKeyPropertyName = "apiKey";
|
||||
|
||||
return {
|
||||
title: "Using the client",
|
||||
code: `
|
||||
import { Shopify } from "@trigger.dev/shopify";
|
||||
|
||||
const shopify = new Shopify({
|
||||
id: "__SLUG__",${hasApiKey ? `\n ${apiKeyPropertyName}: process.env.SHOPIFY_API_KEY!,` : ""}
|
||||
apiSecretKey: process.env.SHOPIFY_API_SECRET_KEY!,
|
||||
adminAccessToken: process.env.SHOPIFY_ADMIN_ACCESS_TOKEN!,
|
||||
hostName: process.env.SHOPIFY_SHOP_DOMAIN!,
|
||||
});
|
||||
|
||||
client.defineJob({
|
||||
id: "shopify-create-product",
|
||||
name: "Shopify: Create Product",
|
||||
version: "0.1.0",
|
||||
integrations: { shopify },
|
||||
trigger: eventTrigger({
|
||||
name: "shopify.product.create",
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
}),
|
||||
}),
|
||||
run: async (payload, io, ctx) => {
|
||||
const product = await io.shopify.rest.Product.save("create-product", {
|
||||
fromData: {
|
||||
title: payload.title,
|
||||
},
|
||||
});
|
||||
|
||||
await io.logger.info(\`Created product \${product.id}: \${product.title}\`);
|
||||
},
|
||||
});
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const shopify: Integration = {
|
||||
identifier: "shopify",
|
||||
name: "Shopify",
|
||||
packageName: "@trigger.dev/shopify@latest",
|
||||
authenticationMethods: {
|
||||
apikey: {
|
||||
type: "apikey",
|
||||
help: {
|
||||
samples: [usageSample(true)],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const supabase = new SupabaseManagement({
|
||||
id: "__SLUG__",
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "on-new-todos",
|
||||
name: "On New Todos",
|
||||
version: "0.1.1",
|
||||
@@ -32,7 +32,7 @@ const supabase = new SupabaseManagement({
|
||||
apiKey: process.env.SUPABASE_API_KEY!,
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "on-new-todos",
|
||||
name: "On New Todos",
|
||||
version: "0.1.1",
|
||||
@@ -136,7 +136,7 @@ const supabase = new Supabase<Database>({
|
||||
supabaseKey: process.env.SUPABASE_API_KEY!,
|
||||
});
|
||||
|
||||
new Job(client, {
|
||||
client.defineJob({
|
||||
id: "on-new-users",
|
||||
name: "On New Users",
|
||||
version: "0.1.1",
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { PrismaClient, TriggerHttpEndpoint } from "@trigger.dev/database";
|
||||
import { PrismaClient, RuntimeEnvironment, Webhook } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { RequestFilterSchema, requestFilterMatches } from "@trigger.dev/core";
|
||||
import {
|
||||
RequestFilterSchema,
|
||||
WebhookContextMetadataSchema,
|
||||
requestFilterMatches,
|
||||
} from "@trigger.dev/core";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { IngestSendEvent } from "../events/ingestSendEvent.server";
|
||||
import { getSecretStore } from "../secrets/secretStore.server";
|
||||
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
|
||||
import { ulid } from "../ulid.server";
|
||||
import { env } from "~/env.server";
|
||||
import { HandleWebhookRequestService } from "../sources/handleWebhookRequest.server";
|
||||
|
||||
export const HttpEndpointParamsSchema = z.object({
|
||||
httpEndpointId: z.string(),
|
||||
@@ -34,6 +39,7 @@ export class HandleHttpEndpointService {
|
||||
},
|
||||
include: {
|
||||
secretReference: true,
|
||||
webhook: true,
|
||||
project: {
|
||||
include: {
|
||||
environments: {
|
||||
@@ -108,6 +114,7 @@ export class HandleHttpEndpointService {
|
||||
//get the secret
|
||||
const secretStore = getSecretStore(httpEndpoint.secretReference.provider);
|
||||
let secret: string | undefined;
|
||||
|
||||
try {
|
||||
const secretData = await secretStore.getSecretOrThrow(
|
||||
z.object({ secret: z.string() }),
|
||||
@@ -119,6 +126,7 @@ export class HandleHttpEndpointService {
|
||||
logger.error("Getting secret threw", { error });
|
||||
return json({ error: true, message: "Could not retrieve secret" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
logger.error("Could not find secret", {
|
||||
httpEndpointId: httpEndpoint.id,
|
||||
@@ -132,16 +140,22 @@ export class HandleHttpEndpointService {
|
||||
const callClientImmediately = immediateResponseFilter.data
|
||||
? await requestFilterMatches(request, immediateResponseFilter.data)
|
||||
: false;
|
||||
|
||||
let httpResponse: Response | undefined;
|
||||
|
||||
if (callClientImmediately) {
|
||||
logger.info("Calling client immediately", {
|
||||
httpEndpointId: httpEndpoint.id,
|
||||
environmentId: environment.id,
|
||||
immediateResponseFilter: immediateResponseFilter.data,
|
||||
});
|
||||
|
||||
const clonedRequest = request.clone();
|
||||
|
||||
const client = new EndpointApi(environment.apiKey, httpEndpointEnvironment.endpoint.url);
|
||||
|
||||
const httpRequest = await createHttpSourceRequest(clonedRequest);
|
||||
|
||||
const { response, parser } = await client.deliverHttpEndpointRequestForResponse({
|
||||
key: httpEndpoint.key,
|
||||
secret: secret,
|
||||
@@ -149,7 +163,9 @@ export class HandleHttpEndpointService {
|
||||
});
|
||||
|
||||
const responseJson = await response.json();
|
||||
|
||||
const parsedResponseResult = parser.safeParse(responseJson);
|
||||
|
||||
if (!parsedResponseResult.success) {
|
||||
logger.error("Could not parse response from client", {
|
||||
httpEndpointId: httpEndpoint.id,
|
||||
@@ -157,6 +173,7 @@ export class HandleHttpEndpointService {
|
||||
responseJson,
|
||||
errors: parsedResponseResult.error,
|
||||
});
|
||||
|
||||
return json(
|
||||
{ error: true, message: "Could not parse response from client" },
|
||||
{ status: 500 }
|
||||
@@ -164,6 +181,7 @@ export class HandleHttpEndpointService {
|
||||
}
|
||||
|
||||
const endpointResponse = parsedResponseResult.data;
|
||||
|
||||
httpResponse = new Response(endpointResponse.body, {
|
||||
status: endpointResponse.status,
|
||||
headers: endpointResponse.headers,
|
||||
@@ -186,11 +204,17 @@ export class HandleHttpEndpointService {
|
||||
return httpResponse;
|
||||
}
|
||||
|
||||
if (httpEndpoint.webhook) {
|
||||
return await this.#handleWebhookRequest(request, environment, httpEndpoint.webhook, secret);
|
||||
}
|
||||
|
||||
const ingestService = new IngestSendEvent();
|
||||
|
||||
let rawBody: string | undefined;
|
||||
try {
|
||||
rawBody = await request.text();
|
||||
} catch (e) {}
|
||||
|
||||
const url = requestUrl(request);
|
||||
const event = {
|
||||
headers: Object.fromEntries(request.headers) as Record<string, string>,
|
||||
@@ -223,6 +247,44 @@ export class HandleHttpEndpointService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async #handleWebhookRequest(
|
||||
request: Request,
|
||||
environment: RuntimeEnvironment,
|
||||
webhook: Webhook,
|
||||
secret: string
|
||||
) {
|
||||
const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({
|
||||
where: {
|
||||
environmentId_webhookId: {
|
||||
environmentId: environment.id,
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
endpoint: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhookEnvironment) {
|
||||
logger.debug("Could not find webhook environment", {
|
||||
webhookId: webhook.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return json({ error: true, message: "Could not find webhook environment" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rawContext = {
|
||||
secret,
|
||||
config: webhookEnvironment.config,
|
||||
params: webhook.params,
|
||||
};
|
||||
const webhookContextMetadata = WebhookContextMetadataSchema.parse(rawContext);
|
||||
|
||||
const service = new HandleWebhookRequestService(this.#prismaClient);
|
||||
|
||||
return await service.call(webhookEnvironment.id, request, webhookContextMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
type GetHttpEndpointUrlParams = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
JobMetadata,
|
||||
SCHEDULED_EVENT,
|
||||
TriggerMetadata,
|
||||
assertExhaustive,
|
||||
} from "@trigger.dev/core";
|
||||
import type { Endpoint, Integration, Job, JobIntegration, JobVersion } from "@trigger.dev/database";
|
||||
import { DEFAULT_MAX_CONCURRENT_RUNS } from "~/consts";
|
||||
@@ -13,6 +14,7 @@ import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { RegisterScheduleSourceService } from "../schedules/registerScheduleSource.server";
|
||||
import { executionRateLimiter } from "../runExecutionRateLimiter.server";
|
||||
|
||||
export class RegisterJobService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -104,32 +106,28 @@ export class RegisterJobService {
|
||||
},
|
||||
});
|
||||
|
||||
// Upsert the JobQueue
|
||||
const queueName = "default";
|
||||
const { examples, ...eventSpecification } = metadata.event;
|
||||
|
||||
// Job Queues are going to be deprecated or used for something else, we're just doing this for now
|
||||
const jobQueue = await this.#prismaClient.jobQueue.upsert({
|
||||
where: {
|
||||
environmentId_name: {
|
||||
environmentId: environment.id,
|
||||
name: queueName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
environment: {
|
||||
connect: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
name: queueName,
|
||||
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
},
|
||||
update: {
|
||||
maxJobs: DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
},
|
||||
});
|
||||
|
||||
const { examples, ...eventSpecification } = metadata.event;
|
||||
const concurrencyLimitGroup =
|
||||
typeof metadata.concurrencyLimit === "object"
|
||||
? await this.#prismaClient.concurrencyLimitGroup.upsert({
|
||||
where: {
|
||||
environmentId_name: {
|
||||
environmentId: environment.id,
|
||||
name: metadata.concurrencyLimit.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
environmentId: environment.id,
|
||||
name: metadata.concurrencyLimit.id,
|
||||
concurrencyLimit: metadata.concurrencyLimit.limit,
|
||||
},
|
||||
update: {
|
||||
concurrencyLimit: metadata.concurrencyLimit.limit,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// Upsert the JobVersion
|
||||
const jobVersion = await this.#prismaClient.jobVersion.upsert({
|
||||
@@ -141,57 +139,29 @@ export class RegisterJobService {
|
||||
},
|
||||
},
|
||||
create: {
|
||||
job: {
|
||||
connect: {
|
||||
id: job.id,
|
||||
},
|
||||
},
|
||||
endpoint: {
|
||||
connect: {
|
||||
id: endpoint.id,
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
connect: {
|
||||
id: environment.organizationId,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
connect: {
|
||||
id: environment.projectId,
|
||||
},
|
||||
},
|
||||
queue: {
|
||||
connect: {
|
||||
id: jobQueue.id,
|
||||
},
|
||||
},
|
||||
jobId: job.id,
|
||||
endpointId: endpoint.id,
|
||||
environmentId: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
version: metadata.version,
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
startPosition: "LATEST",
|
||||
status: "ACTIVE",
|
||||
concurrencyLimitGroupId: concurrencyLimitGroup?.id ?? null,
|
||||
concurrencyLimit:
|
||||
typeof metadata.concurrencyLimit === "number" ? metadata.concurrencyLimit : null,
|
||||
},
|
||||
update: {
|
||||
status: "ACTIVE",
|
||||
startPosition: "LATEST",
|
||||
eventSpecification,
|
||||
preprocessRuns: metadata.preprocessRuns,
|
||||
queue: {
|
||||
connect: {
|
||||
id: jobQueue.id,
|
||||
},
|
||||
},
|
||||
endpoint: {
|
||||
connect: {
|
||||
id: endpoint.id,
|
||||
},
|
||||
},
|
||||
endpointId: endpoint.id,
|
||||
concurrencyLimitGroupId: concurrencyLimitGroup?.id ?? null,
|
||||
concurrencyLimit:
|
||||
typeof metadata.concurrencyLimit === "number" ? metadata.concurrencyLimit : null,
|
||||
},
|
||||
include: {
|
||||
integrations: {
|
||||
@@ -199,9 +169,28 @@ export class RegisterJobService {
|
||||
integration: true,
|
||||
},
|
||||
},
|
||||
concurrencyLimitGroup: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (jobVersion.concurrencyLimitGroup) {
|
||||
// Upsert the maxSize for the concurrency limit group
|
||||
await executionRateLimiter?.putConcurrencyLimitGroup(
|
||||
jobVersion.concurrencyLimitGroup,
|
||||
environment
|
||||
);
|
||||
}
|
||||
|
||||
await executionRateLimiter?.putJobVersionConcurrencyLimit(jobVersion, environment);
|
||||
} catch (error) {
|
||||
logger.error("Error setting concurrency limit", {
|
||||
error,
|
||||
jobVersionId: jobVersion.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert the examples and delete any that are no longer in the metadata
|
||||
const upsertedExamples = new Set<string>();
|
||||
if (examples) {
|
||||
@@ -638,7 +627,3 @@ export class RegisterJobService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertExhaustive(x: never): never {
|
||||
throw new Error("Unexpected object: " + x);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LogLevel } from "@trigger.dev/core";
|
||||
import { Logger } from "@trigger.dev/core";
|
||||
import type { LogLevel } from "@trigger.dev/core-backend";
|
||||
import { Logger } from "@trigger.dev/core-backend";
|
||||
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
Callback,
|
||||
Cluster,
|
||||
ClusterNode,
|
||||
ClusterOptions,
|
||||
Redis,
|
||||
RedisOptions,
|
||||
Result,
|
||||
} from "ioredis";
|
||||
import { JobHelpers, Task } from "graphile-worker";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { logger } from "./logger.server";
|
||||
import { ZodWorkerRateLimiter } from "~/platform/zodWorker.server";
|
||||
import {
|
||||
ConcurrencyLimitGroup,
|
||||
JobRun,
|
||||
JobVersion,
|
||||
RuntimeEnvironment,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
export interface RunExecutionRateLimiter {
|
||||
putConcurrencyLimitGroup(
|
||||
concurrencyLimitGroup: ConcurrencyLimitGroup,
|
||||
env: RuntimeEnvironment
|
||||
): Promise<void>;
|
||||
putJobVersionConcurrencyLimit(jobVersion: JobVersion, env: RuntimeEnvironment): Promise<void>;
|
||||
setMaxSizeForFlag(flag: string, maxSize: number): Promise<void>;
|
||||
delMaxSizeForFlag(flag: string): Promise<void>;
|
||||
flagsForRun(
|
||||
run: JobRun,
|
||||
version: JobVersion & {
|
||||
environment: RuntimeEnvironment;
|
||||
concurrencyLimitGroup?: ConcurrencyLimitGroup;
|
||||
}
|
||||
): string[];
|
||||
}
|
||||
|
||||
declare module "ioredis" {
|
||||
interface RedisCommander<Context> {
|
||||
beforeTask(
|
||||
setKey: string,
|
||||
maxSizeKey: string,
|
||||
forbiddenFlagsKey: string,
|
||||
jobId: string,
|
||||
timestamp: string,
|
||||
windowSize: string,
|
||||
forbiddenFlag: string,
|
||||
maxSize: string,
|
||||
callback?: Callback<string>
|
||||
): Result<number | null, Context>;
|
||||
rollbackBeforeTask(keys: number, ...args: string[]): Result<string, Context>;
|
||||
|
||||
afterTask(
|
||||
setKey: string,
|
||||
maxSizeKey: string,
|
||||
forbiddenFlagsKey: string,
|
||||
jobId: string,
|
||||
timestamp: string,
|
||||
windowSize: string,
|
||||
forbiddenFlag: string,
|
||||
maxSize: string,
|
||||
callback?: Callback<string>
|
||||
): Result<number | null, Context>;
|
||||
}
|
||||
}
|
||||
|
||||
type RedisRunExecutionRateLimiterOptions = {
|
||||
redis?: RedisOptions;
|
||||
cluster?: {
|
||||
startupNodes: ClusterNode[];
|
||||
options?: ClusterOptions;
|
||||
};
|
||||
defaultConcurrency?: number;
|
||||
windowSize?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
const FORBIDDEN_FLAG_KEY = "forbiddenFlags";
|
||||
const KEY_PREFIX = "tr:exec:";
|
||||
|
||||
class RedisRunExecutionRateLimiter implements RunExecutionRateLimiter, ZodWorkerRateLimiter {
|
||||
private redis: Redis | Cluster;
|
||||
private defaultMaxSize: number;
|
||||
private windowSize: number;
|
||||
|
||||
constructor(options?: RedisRunExecutionRateLimiterOptions) {
|
||||
this.redis = options?.cluster
|
||||
? new Redis.Cluster(options.cluster.startupNodes, options.cluster.options)
|
||||
: new Redis(options?.redis ?? {});
|
||||
this.defaultMaxSize = options?.defaultConcurrency ?? 10;
|
||||
this.windowSize = options?.windowSize ?? 1000 * 15 * 60; // 2 minutes
|
||||
|
||||
this.redis.defineCommand("beforeTask", {
|
||||
numberOfKeys: 3,
|
||||
lua: `
|
||||
local setKey = KEYS[1]
|
||||
local maxSizeKey = KEYS[2]
|
||||
local forbiddenFlagsKey = KEYS[3]
|
||||
local jobId = ARGV[1]
|
||||
local timestamp = ARGV[2]
|
||||
local windowSize = ARGV[3]
|
||||
local forbiddenFlag = ARGV[4]
|
||||
local defaultMaxSize = ARGV[5]
|
||||
|
||||
local maxSize = tonumber(redis.call('GET', maxSizeKey) or defaultMaxSize)
|
||||
local currentSize = redis.call('ZCOUNT', setKey, timestamp - windowSize, timestamp)
|
||||
|
||||
if currentSize < maxSize then
|
||||
redis.call('ZADD', setKey, timestamp, jobId)
|
||||
|
||||
return true
|
||||
else
|
||||
redis.call('SADD', forbiddenFlagsKey, forbiddenFlag)
|
||||
|
||||
return false
|
||||
end
|
||||
`,
|
||||
});
|
||||
|
||||
// This will remove the job ID from the ZSET
|
||||
this.redis.defineCommand("rollbackBeforeTask", {
|
||||
lua: `
|
||||
for i, key in ipairs(KEYS) do
|
||||
redis.call('ZREM', key, ARGV[1])
|
||||
end
|
||||
`,
|
||||
});
|
||||
|
||||
this.redis.defineCommand("afterTask", {
|
||||
numberOfKeys: 3,
|
||||
lua: `
|
||||
local setKey = KEYS[1]
|
||||
local maxSizeKey = KEYS[2]
|
||||
local forbiddenFlagsKey = KEYS[3]
|
||||
local jobId = ARGV[1]
|
||||
local timestamp = ARGV[2]
|
||||
local windowSize = ARGV[3]
|
||||
local forbiddenFlag = ARGV[4]
|
||||
local defaultMaxSize = ARGV[5]
|
||||
|
||||
local maxSize = tonumber(redis.call('GET', maxSizeKey) or defaultMaxSize)
|
||||
|
||||
-- Remove the job ID from the ZSET
|
||||
redis.call('ZREM', setKey, jobId)
|
||||
|
||||
-- Count the current number of jobs in the window
|
||||
local currentSize = redis.call('ZCOUNT', setKey, timestamp - windowSize, timestamp)
|
||||
|
||||
-- The cleanup of old job IDs is now an essential part of maintaining the ZSET's size
|
||||
redis.call('ZREMRANGEBYSCORE', setKey, '-inf', timestamp - windowSize)
|
||||
|
||||
-- Update the forbidden flags based on the current size
|
||||
if currentSize < maxSize then
|
||||
-- Only remove the forbidden flag if it's no longer needed
|
||||
redis.call('SREM', forbiddenFlagsKey, forbiddenFlag)
|
||||
return true
|
||||
else
|
||||
-- No need to add the forbidden flag here as it should be handled in beforeTask
|
||||
return false
|
||||
end
|
||||
|
||||
`,
|
||||
});
|
||||
|
||||
if (this.redis instanceof Redis) {
|
||||
logger.debug("⚡ RedisGraphileRateLimiter connected to Redis", {
|
||||
host: this.redis.options.host,
|
||||
port: this.redis.options.port,
|
||||
});
|
||||
} else {
|
||||
logger.debug("⚡ RedisGraphileRateLimiter connected to Redis Cluster", {
|
||||
nodes: this.redis.nodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async forbiddenFlags(): Promise<string[]> {
|
||||
return this.redis.smembers(FORBIDDEN_FLAG_KEY);
|
||||
}
|
||||
|
||||
async putConcurrencyLimitGroup(
|
||||
concurrencyLimitGroup: ConcurrencyLimitGroup,
|
||||
env: RuntimeEnvironment
|
||||
): Promise<void> {
|
||||
await this.setMaxSizeForFlag(
|
||||
this.flagForConcurrencyLimitGroup(concurrencyLimitGroup, env),
|
||||
concurrencyLimitGroup.concurrencyLimit
|
||||
);
|
||||
}
|
||||
|
||||
async putJobVersionConcurrencyLimit(
|
||||
jobVersion: JobVersion,
|
||||
env: RuntimeEnvironment
|
||||
): Promise<void> {
|
||||
const flag = this.flagForJobVersion(jobVersion, env);
|
||||
|
||||
if (typeof jobVersion.concurrencyLimit === "number" && jobVersion.concurrencyLimit > 0) {
|
||||
await this.setMaxSizeForFlag(flag, jobVersion.concurrencyLimit);
|
||||
} else {
|
||||
await this.delMaxSizeForFlag(flag);
|
||||
}
|
||||
}
|
||||
|
||||
flagsForRun(
|
||||
run: JobRun,
|
||||
version: JobVersion & {
|
||||
environment: RuntimeEnvironment;
|
||||
concurrencyLimitGroup?: ConcurrencyLimitGroup | null;
|
||||
}
|
||||
): string[] {
|
||||
const flags = [this.flagForOrganization(run)];
|
||||
|
||||
if (version.concurrencyLimitGroup) {
|
||||
flags.push(
|
||||
this.flagForConcurrencyLimitGroup(version.concurrencyLimitGroup, version.environment)
|
||||
);
|
||||
} else if (typeof version.concurrencyLimit === "number" && version.concurrencyLimit > 0) {
|
||||
flags.push(this.flagForJobVersion(version, version.environment));
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
flagForConcurrencyLimitGroup(
|
||||
concurrencyLimitGroup: ConcurrencyLimitGroup,
|
||||
env: RuntimeEnvironment
|
||||
): string {
|
||||
return `rl:group:${env.id}:${env.slug}:${concurrencyLimitGroup.name}`;
|
||||
}
|
||||
|
||||
flagForOrganization(run: JobRun): string {
|
||||
return `rl:org:${run.organizationId}`;
|
||||
}
|
||||
|
||||
flagForJobVersion(version: JobVersion, env: RuntimeEnvironment): string {
|
||||
return `rl:job:${env.slug}:${version.id}`;
|
||||
}
|
||||
|
||||
async setMaxSizeForFlag(flag: string, maxSize: number): Promise<void> {
|
||||
await this.redis.set(`${flag}:maxSize`, String(maxSize));
|
||||
}
|
||||
|
||||
async delMaxSizeForFlag(flag: string): Promise<void> {
|
||||
await this.redis.del(`${flag}:maxSize`);
|
||||
}
|
||||
|
||||
wrapTask(t: Task, rescheduler: Task): Task {
|
||||
return async (payload: unknown, helpers: JobHelpers) => {
|
||||
const flags = Object.keys(helpers.job.flags ?? {}).filter((flag) => flag.startsWith("rl:"));
|
||||
|
||||
if (flags.length === 0) {
|
||||
return t(payload, helpers);
|
||||
}
|
||||
|
||||
let passedFlags = [];
|
||||
|
||||
for (const flag of flags) {
|
||||
const result = await this.#callBeforeTask(flag, String(helpers.job.id));
|
||||
|
||||
if (
|
||||
(result.status === "fulfilled" && result.value === null) ||
|
||||
result.status === "rejected"
|
||||
) {
|
||||
logger.debug("Rolling back passed flags", {
|
||||
flag,
|
||||
passedFlags,
|
||||
jobId: String(helpers.job.id),
|
||||
result,
|
||||
});
|
||||
// If there are any passed flags, we need to roll them back
|
||||
await this.#rollbackPassedFlags(passedFlags, String(helpers.job.id));
|
||||
|
||||
return await rescheduler(payload, helpers);
|
||||
}
|
||||
|
||||
passedFlags.push(flag);
|
||||
}
|
||||
|
||||
try {
|
||||
await t(payload, helpers);
|
||||
} finally {
|
||||
const afterResults = await Promise.allSettled(
|
||||
flags.map(async (flag) => this.#callAfterTask(flag, String(helpers.job.id)))
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async #callBeforeTask(
|
||||
flag: string,
|
||||
jobId: string
|
||||
): Promise<
|
||||
| { status: "fulfilled"; value: number | null; durationInMs: number }
|
||||
| { status: "rejected"; error: any }
|
||||
> {
|
||||
try {
|
||||
const now = performance.now();
|
||||
const value = await this.redis.beforeTask(
|
||||
flag,
|
||||
`${flag}:maxSize`,
|
||||
FORBIDDEN_FLAG_KEY,
|
||||
jobId,
|
||||
String(Date.now()),
|
||||
String(this.windowSize),
|
||||
flag,
|
||||
String(this.defaultMaxSize)
|
||||
);
|
||||
|
||||
const durationInMs = performance.now() - now;
|
||||
|
||||
return {
|
||||
status: "fulfilled",
|
||||
value,
|
||||
durationInMs,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to call beforeTask", { error, flag, jobId });
|
||||
|
||||
return {
|
||||
status: "rejected",
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Method for rolling back passed flags using a single Lua script
|
||||
async #rollbackPassedFlags(passedFlags: string[], jobId: string) {
|
||||
if (passedFlags.length > 0) {
|
||||
await this.redis.rollbackBeforeTask(passedFlags.length, ...passedFlags, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
async #callAfterTask(flag: string, jobId: string) {
|
||||
try {
|
||||
const now = performance.now();
|
||||
|
||||
const results = await this.redis.afterTask(
|
||||
flag,
|
||||
`${flag}:maxSize`,
|
||||
FORBIDDEN_FLAG_KEY,
|
||||
jobId,
|
||||
String(Date.now()),
|
||||
String(this.windowSize),
|
||||
flag,
|
||||
String(this.defaultMaxSize)
|
||||
);
|
||||
|
||||
const durationInMs = performance.now() - now;
|
||||
|
||||
return {
|
||||
results,
|
||||
durationInMs,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to call afterTask", { error, flag, jobId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const executionRateLimiter = singleton("execution-rate-limiter", getRateLimiter);
|
||||
|
||||
function getRateLimiter() {
|
||||
if (env.REDIS_HOST && env.REDIS_PORT) {
|
||||
if (env.REDIS_READER_HOST) {
|
||||
return new RedisRunExecutionRateLimiter({
|
||||
cluster: {
|
||||
startupNodes: [
|
||||
{ host: env.REDIS_HOST, port: env.REDIS_PORT },
|
||||
{ host: env.REDIS_READER_HOST, port: env.REDIS_READER_PORT ?? env.REDIS_PORT },
|
||||
],
|
||||
options: {
|
||||
keyPrefix: KEY_PREFIX,
|
||||
scaleReads: "slave",
|
||||
redisOptions: {
|
||||
password: env.REDIS_PASSWORD,
|
||||
tls: {
|
||||
checkServerIdentity: () => {
|
||||
// disable TLS verification
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
enableAutoPipelining: true,
|
||||
},
|
||||
dnsLookup: (address, callback) => callback(null, address),
|
||||
slotsRefreshTimeout: 10000,
|
||||
},
|
||||
},
|
||||
defaultConcurrency: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
});
|
||||
} else {
|
||||
return new RedisRunExecutionRateLimiter({
|
||||
redis: {
|
||||
keyPrefix: KEY_PREFIX,
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} })
|
||||
},
|
||||
defaultConcurrency: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { executionWorker } from "../worker.server";
|
||||
import { dequeueRunExecutionV3 } from "~/models/jobRunExecution.server";
|
||||
import { PerformRunExecutionV3Service } from "./performRunExecutionV3.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
|
||||
export class CancelRunService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -39,7 +39,8 @@ export class CancelRunService {
|
||||
},
|
||||
});
|
||||
|
||||
await dequeueRunExecutionV3(run, tx);
|
||||
await PerformRunExecutionV3Service.dequeue(run, tx);
|
||||
await ResumeRunService.dequeue(run, tx);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
|
||||
const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "UNRESOLVED_AUTH", "ABORTED", "CANCELED"];
|
||||
|
||||
@@ -39,9 +38,7 @@ export class ContinueRunService {
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(run, tx);
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
@@ -31,12 +31,6 @@ export class CreateRunService {
|
||||
},
|
||||
});
|
||||
|
||||
const jobQueue = await this.#prismaClient.jobQueue.findUniqueOrThrow({
|
||||
where: {
|
||||
id: version.queueId,
|
||||
},
|
||||
});
|
||||
|
||||
const eventRecord = await this.#prismaClient.eventRecord.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventId,
|
||||
@@ -44,22 +38,8 @@ export class CreateRunService {
|
||||
});
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
// Get the current max number for the given jobId
|
||||
const latestJob = await tx.jobRun.findFirst({
|
||||
where: { jobId: job.id },
|
||||
orderBy: { id: "desc" },
|
||||
select: {
|
||||
number: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Increment the number for the new execution
|
||||
const newNumber = (latestJob?.number ?? 0) + 1;
|
||||
|
||||
// Create the new execution with the incremented number
|
||||
const run = await tx.jobRun.create({
|
||||
data: {
|
||||
number: newNumber,
|
||||
preprocess: version.preprocessRuns,
|
||||
jobId: job.id,
|
||||
versionId: version.id,
|
||||
@@ -68,7 +48,6 @@ export class CreateRunService {
|
||||
organizationId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
endpointId: endpoint.id,
|
||||
queueId: jobQueue.id,
|
||||
externalAccountId: eventRecord.externalAccountId
|
||||
? eventRecord.externalAccountId
|
||||
: undefined,
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
supportsFeature,
|
||||
} from "@trigger.dev/core";
|
||||
import { BloomFilter } from "@trigger.dev/core-backend";
|
||||
import { RuntimeEnvironmentType, type Task } from "@trigger.dev/database";
|
||||
import {
|
||||
ConcurrencyLimitGroup,
|
||||
JobRun,
|
||||
JobVersion,
|
||||
RuntimeEnvironment,
|
||||
} from "@trigger.dev/database";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { eventRecordToApiJson } from "~/api.server";
|
||||
import {
|
||||
@@ -26,7 +31,7 @@ import {
|
||||
} from "~/consts";
|
||||
import { $transaction, PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { detectResponseIsTimeout } from "~/models/endpoint.server";
|
||||
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
|
||||
import { isRunCompleted } from "~/models/jobRun.server";
|
||||
import { resolveRunConnections } from "~/models/runConnection.server";
|
||||
import { prepareTasksForCaching, prepareTasksForCachingLegacy } from "~/models/task.server";
|
||||
import { CompleteRunTaskService } from "~/routes/api.v1.runs.$runId.tasks.$id.complete";
|
||||
@@ -36,8 +41,11 @@ import { EndpointApi } from "../endpointApi.server";
|
||||
import { createExecutionEvent } from "../executions/createExecutionEvent.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { ResumeTaskService } from "../tasks/resumeTask.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { executionWorker, workerQueue } from "../worker.server";
|
||||
import { forceYieldCoordinator } from "./forceYieldCoordinator.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
import { executionRateLimiter } from "../runExecutionRateLimiter.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type FoundTask = FoundRun["tasks"][number];
|
||||
@@ -58,8 +66,15 @@ export type PerformRunExecutionV3Input = {
|
||||
* @deprecated Resuming tasks now goes through ResumeTaskService, this is included here for backwards compatibility
|
||||
*/
|
||||
resumeTaskId?: string;
|
||||
|
||||
/**
|
||||
* Specifies whether this should be the last attempt to execute the run. If so, we can't retry the run in case of a failure.
|
||||
*/
|
||||
lastAttempt: boolean;
|
||||
};
|
||||
|
||||
export type RunExecutionPriority = "initial" | "resume";
|
||||
|
||||
export class PerformRunExecutionV3Service {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -74,206 +89,85 @@ export class PerformRunExecutionV3Service {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (input.reason) {
|
||||
case "PREPROCESS": {
|
||||
await this.#executePreprocessing(run);
|
||||
break;
|
||||
}
|
||||
case "EXECUTE_JOB": {
|
||||
await this.#executeJob(run, input, driftInMs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await this.#executeJob(run, input, driftInMs);
|
||||
}
|
||||
|
||||
// Execute the preprocessing step of a run, which will send the payload to the endpoint and give the job
|
||||
// an opportunity to generate run properties based on the payload.
|
||||
// If the endpoint is not available, or the response is not ok,
|
||||
// the run execution will be marked as failed and the run will start
|
||||
async #executePreprocessing(run: FoundRun) {
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const { response, parser } = await client.preprocessRunRequest({
|
||||
event,
|
||||
job: {
|
||||
id: run.version.job.slug,
|
||||
version: run.version.version,
|
||||
},
|
||||
run: {
|
||||
static async enqueue(
|
||||
run: JobRun & {
|
||||
version: JobVersion & {
|
||||
environment: RuntimeEnvironment;
|
||||
concurrencyLimitGroup?: ConcurrencyLimitGroup | null;
|
||||
};
|
||||
},
|
||||
priority: RunExecutionPriority,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options: {
|
||||
runAt?: Date;
|
||||
skipRetrying?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return await executionWorker.enqueue(
|
||||
"performRunExecutionV3",
|
||||
{
|
||||
id: run.id,
|
||||
isTest: run.isTest,
|
||||
reason: "EXECUTE_JOB",
|
||||
},
|
||||
environment: {
|
||||
id: run.environment.id,
|
||||
slug: run.environment.slug,
|
||||
type: run.environment.type,
|
||||
},
|
||||
organization: {
|
||||
id: run.organization.id,
|
||||
slug: run.organization.slug,
|
||||
title: run.organization.title,
|
||||
},
|
||||
account: run.externalAccount
|
||||
? {
|
||||
id: run.externalAccount.identifier,
|
||||
metadata: run.externalAccount.metadata,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: "Could not connect to the endpoint",
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await response.text();
|
||||
const safeBody = safeJsonZodParse(parser, rawBody);
|
||||
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
});
|
||||
}
|
||||
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(this.#prismaClient, "PREPROCESS", run, {
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
if (safeBody.data.abort) {
|
||||
return this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"PREPROCESS",
|
||||
run,
|
||||
{ message: "Endpoint aborted the run" },
|
||||
"ABORTED"
|
||||
);
|
||||
} else {
|
||||
await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
properties: safeBody.data.properties,
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
tx,
|
||||
runAt: options.runAt,
|
||||
jobKey: `job_run:EXECUTE_JOB:${run.id}`,
|
||||
maxAttempts: options.skipRetrying ? env.DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS : undefined,
|
||||
flags: executionRateLimiter?.flagsForRun(run, run.version) ?? [],
|
||||
priority: priority === "initial" ? 0 : -1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static async dequeue(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
await executionWorker.dequeue(`job_run:EXECUTE_JOB:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
|
||||
async #executeJob(run: FoundRun, input: PerformRunExecutionV3Input, driftInMs: number = 0) {
|
||||
try {
|
||||
const { isRetry, resumeTaskId } = input;
|
||||
|
||||
if (run.status === "CANCELED") {
|
||||
await this.#cancelExecution(run);
|
||||
if (isRunCompleted(run.status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof process.env.BLOCKED_ORGS === "string" &&
|
||||
process.env.BLOCKED_ORGS.includes(run.organizationId)
|
||||
) {
|
||||
logger.debug("Skipping execution for blocked org", {
|
||||
orgId: run.organizationId,
|
||||
});
|
||||
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "CANCELED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
const { executionCount } = await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: run.status === "QUEUED" ? "STARTED" : run.status,
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const connections = await resolveRunConnections(run.runConnections);
|
||||
|
||||
if (!connections.success) {
|
||||
return this.#failRunExecution(this.#prismaClient, "EXECUTE_JOB", run, {
|
||||
return this.#failRunExecution(this.#prismaClient, run, {
|
||||
message: `Could not resolve all connections for run ${run.id}. This should not happen`,
|
||||
});
|
||||
}
|
||||
|
||||
let resumedTask: Task | undefined;
|
||||
|
||||
if (resumeTaskId) {
|
||||
resumedTask =
|
||||
(await this.#prismaClient.task.findUnique({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
})) ?? undefined;
|
||||
|
||||
if (resumedTask) {
|
||||
resumedTask = await this.#prismaClient.task.update({
|
||||
where: {
|
||||
id: resumeTaskId,
|
||||
},
|
||||
data: {
|
||||
status: resumedTask.noop ? "COMPLETED" : "RUNNING",
|
||||
completedAt: resumedTask.noop ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sourceContext = RunSourceContextSchema.safeParse(run.event.sourceContext);
|
||||
|
||||
const executionBody = await this.#createExecutionBody(
|
||||
run,
|
||||
[run.tasks, resumedTask].flat().filter(Boolean),
|
||||
run.tasks,
|
||||
startedAt,
|
||||
isRetry,
|
||||
false,
|
||||
connections.auth,
|
||||
event,
|
||||
sourceContext.success ? sourceContext.data : undefined
|
||||
);
|
||||
|
||||
forceYieldCoordinator.registerRun(run.id);
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "EXECUTING",
|
||||
},
|
||||
});
|
||||
|
||||
await createExecutionEvent({
|
||||
eventType: "start",
|
||||
@@ -284,8 +178,12 @@ export class PerformRunExecutionV3Service {
|
||||
projectId: run.projectId,
|
||||
jobId: run.jobId,
|
||||
runId: run.id,
|
||||
concurrencyLimitGroupId: run.version.concurrencyLimitGroupId,
|
||||
});
|
||||
|
||||
forceYieldCoordinator.registerRun(run.id);
|
||||
|
||||
// TODO: add the ability to abort the execution from any server using Redis pub/sub
|
||||
const { response, parser, errorParser, headersParser, durationInMs } =
|
||||
await client.executeJobRequest(executionBody);
|
||||
|
||||
@@ -298,14 +196,20 @@ export class PerformRunExecutionV3Service {
|
||||
projectId: run.projectId,
|
||||
jobId: run.jobId,
|
||||
runId: run.id,
|
||||
concurrencyLimitGroupId: run.version.concurrencyLimitGroupId,
|
||||
});
|
||||
|
||||
forceYieldCoordinator.deregisterRun(run.id);
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
});
|
||||
return await this.#failRunExecutionWithRetry(
|
||||
run,
|
||||
input.lastAttempt,
|
||||
{
|
||||
message: `Connection could not be established to the endpoint (${run.endpoint.url})`,
|
||||
},
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
|
||||
// Update the endpoint version if it has changed
|
||||
@@ -386,6 +290,8 @@ export class PerformRunExecutionV3Service {
|
||||
status: response.status,
|
||||
runId: run.id,
|
||||
endpoint: run.endpoint.url,
|
||||
headers: rawHeaders,
|
||||
rawBody,
|
||||
});
|
||||
|
||||
const errorBody = safeJsonZodParse(errorParser, rawBody);
|
||||
@@ -393,14 +299,14 @@ export class PerformRunExecutionV3Service {
|
||||
if (errorBody && errorBody.success) {
|
||||
// Only retry if the error isn't a 4xx
|
||||
if (response.status >= 400 && response.status <= 499) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
errorBody.data
|
||||
);
|
||||
return await this.#failRunExecution(this.#prismaClient, run, errorBody.data);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry(errorBody.data);
|
||||
return await this.#failRunExecutionWithRetry(
|
||||
run,
|
||||
input.lastAttempt,
|
||||
errorBody.data,
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +314,6 @@ export class PerformRunExecutionV3Service {
|
||||
if (response.status >= 400 && response.status <= 499 && response.status !== 408) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
@@ -418,18 +323,22 @@ export class PerformRunExecutionV3Service {
|
||||
);
|
||||
} else {
|
||||
// If the error is a timeout, we should mark this execution as succeeded (by not throwing an error) and enqueue a new execution
|
||||
if (detectResponseIsTimeout(response)) {
|
||||
if (detectResponseIsTimeout(rawBody, response)) {
|
||||
return await this.#resumeRunExecutionAfterTimeout(
|
||||
this.#prismaClient,
|
||||
run,
|
||||
input,
|
||||
durationInMs,
|
||||
executionCount
|
||||
durationInMs
|
||||
);
|
||||
} else {
|
||||
return await this.#failRunExecutionWithRetry({
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
});
|
||||
return await this.#failRunExecutionWithRetry(
|
||||
run,
|
||||
input.lastAttempt,
|
||||
{
|
||||
message: `Endpoint responded with ${response.status} status code`,
|
||||
},
|
||||
durationInMs
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,7 +348,6 @@ export class PerformRunExecutionV3Service {
|
||||
if (!safeBody) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: "Endpoint responded with invalid JSON",
|
||||
@@ -452,7 +360,6 @@ export class PerformRunExecutionV3Service {
|
||||
if (!safeBody.success) {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: generateErrorMessage(safeBody.error.issues),
|
||||
@@ -491,7 +398,6 @@ export class PerformRunExecutionV3Service {
|
||||
break;
|
||||
}
|
||||
case "CANCELED": {
|
||||
await this.#cancelExecution(run);
|
||||
break;
|
||||
}
|
||||
case "UNRESOLVED_AUTH_ERROR": {
|
||||
@@ -644,6 +550,9 @@ export class PerformRunExecutionV3Service {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -661,17 +570,18 @@ export class PerformRunExecutionV3Service {
|
||||
run: FoundRun,
|
||||
data: RunJobResumeWithTask,
|
||||
durationInMs: number,
|
||||
executionCount: number = 1
|
||||
executionCountIncrement: number = 1
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: "WAITING_TO_CONTINUE",
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: executionCount,
|
||||
increment: executionCountIncrement,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -744,7 +654,6 @@ export class PerformRunExecutionV3Service {
|
||||
case "ERROR": {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
childError.error ?? undefined,
|
||||
"FAILURE",
|
||||
@@ -754,7 +663,6 @@ export class PerformRunExecutionV3Service {
|
||||
case "INVALID_PAYLOAD": {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
childError.errors,
|
||||
"INVALID_PAYLOAD",
|
||||
@@ -774,7 +682,6 @@ export class PerformRunExecutionV3Service {
|
||||
case "UNRESOLVED_AUTH_ERROR": {
|
||||
return await this.#failRunExecution(
|
||||
this.#prismaClient,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
childError.issues,
|
||||
"UNRESOLVED_AUTH",
|
||||
@@ -805,14 +712,7 @@ export class PerformRunExecutionV3Service {
|
||||
});
|
||||
}
|
||||
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
execution,
|
||||
data.error ?? undefined,
|
||||
"FAILURE",
|
||||
durationInMs
|
||||
);
|
||||
await this.#failRunExecution(tx, execution, data.error ?? undefined, "FAILURE", durationInMs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -822,14 +722,7 @@ export class PerformRunExecutionV3Service {
|
||||
durationInMs: number
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
execution,
|
||||
data.issues,
|
||||
"UNRESOLVED_AUTH",
|
||||
durationInMs
|
||||
);
|
||||
await this.#failRunExecution(tx, execution, data.issues, "UNRESOLVED_AUTH", durationInMs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -839,14 +732,7 @@ export class PerformRunExecutionV3Service {
|
||||
durationInMs: number
|
||||
) {
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
execution,
|
||||
data.errors,
|
||||
"INVALID_PAYLOAD",
|
||||
durationInMs
|
||||
);
|
||||
await this.#failRunExecution(tx, execution, data.errors, "INVALID_PAYLOAD", durationInMs);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -860,7 +746,6 @@ export class PerformRunExecutionV3Service {
|
||||
if (run.yieldedExecutions.length + 1 > MAX_RUN_YIELDED_EXECUTIONS) {
|
||||
return await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Run has yielded too many times, the maximum is ${MAX_RUN_YIELDED_EXECUTIONS}`,
|
||||
@@ -875,6 +760,7 @@ export class PerformRunExecutionV3Service {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
@@ -892,9 +778,7 @@ export class PerformRunExecutionV3Service {
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(run, tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -910,6 +794,7 @@ export class PerformRunExecutionV3Service {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
@@ -933,9 +818,7 @@ export class PerformRunExecutionV3Service {
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(run, tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -968,6 +851,7 @@ export class PerformRunExecutionV3Service {
|
||||
],
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
},
|
||||
select: {
|
||||
executionCount: true,
|
||||
@@ -981,9 +865,7 @@ export class PerformRunExecutionV3Service {
|
||||
output: data.output ? (JSON.parse(data.output) as any) : undefined,
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(run, tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1035,6 +917,7 @@ export class PerformRunExecutionV3Service {
|
||||
status: "WAITING",
|
||||
run: {
|
||||
update: {
|
||||
status: "WAITING_TO_CONTINUE",
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
@@ -1054,8 +937,7 @@ export class PerformRunExecutionV3Service {
|
||||
prisma: PrismaClientOrTransaction,
|
||||
run: FoundRun,
|
||||
input: PerformRunExecutionV3Input,
|
||||
durationInMs: number,
|
||||
executionCount: number
|
||||
durationInMs: number
|
||||
) {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
const executionDuration = run.executionDuration + durationInMs;
|
||||
@@ -1064,7 +946,6 @@ export class PerformRunExecutionV3Service {
|
||||
if (executionDuration >= run.organization.maximumExecutionTimePerRunInMs) {
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Execution timed out after ${
|
||||
@@ -1112,7 +993,6 @@ export class PerformRunExecutionV3Service {
|
||||
|
||||
await this.#failRunExecution(
|
||||
tx,
|
||||
"EXECUTE_JOB",
|
||||
run,
|
||||
{
|
||||
message: `Function timeout detected in ${
|
||||
@@ -1133,6 +1013,9 @@ export class PerformRunExecutionV3Service {
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
endpoint: {
|
||||
update: {
|
||||
// Never allow the execution limit to be less than 10 seconds or more than MAX_RUN_CHUNK_EXECUTION_LIMIT
|
||||
@@ -1143,106 +1026,92 @@ export class PerformRunExecutionV3Service {
|
||||
},
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
},
|
||||
});
|
||||
|
||||
// The run has timed out, so we need to enqueue a new execution
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(run, tx);
|
||||
});
|
||||
}
|
||||
|
||||
async #failRunExecutionWithRetry(output: Record<string, any>): Promise<void> {
|
||||
throw new Error(JSON.stringify(output));
|
||||
async #failRunExecutionWithRetry(
|
||||
run: FoundRun,
|
||||
lastAttempt: boolean,
|
||||
output: Record<string, any>,
|
||||
durationInMs: number = 0
|
||||
): Promise<void> {
|
||||
if (lastAttempt) {
|
||||
return await this.#failRunExecution(this.#prismaClient, run, output);
|
||||
}
|
||||
|
||||
const updatedJob = await this.#prismaClient.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
executionFailureCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedJob.executionFailureCount >= 10) {
|
||||
return await this.#failRunExecution(this.#prismaClient, run, output);
|
||||
}
|
||||
|
||||
// Use the job.executionFailureCount to determine how long to wait before retrying, using an exponential backoff
|
||||
const runAt = new Date(Date.now() + Math.pow(1.5, updatedJob.executionFailureCount) * 500); // 500ms, 750ms, 1125ms, 1687ms, 2531ms, 3796ms, 5694ms, 8541ms, 12812ms, 19218ms
|
||||
|
||||
await ResumeRunService.enqueue(run, this.#prismaClient, runAt);
|
||||
}
|
||||
|
||||
async #failRunExecution(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
reason: "EXECUTE_JOB" | "PREPROCESS",
|
||||
run: FoundRun,
|
||||
output: Record<string, any>,
|
||||
status: "FAILURE" | "ABORTED" | "TIMED_OUT" | "UNRESOLVED_AUTH" | "INVALID_PAYLOAD" = "FAILURE",
|
||||
durationInMs: number = 0
|
||||
): Promise<void> {
|
||||
await $transaction(prisma, async (tx) => {
|
||||
switch (reason) {
|
||||
case "EXECUTE_JOB": {
|
||||
// If the execution is an EXECUTE_JOB reason, we need to fail the run
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
tasks: {
|
||||
updateMany: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["WAITING", "RUNNING", "PENDING"],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status: status === "TIMED_OUT" ? "CANCELED" : "ERRORED",
|
||||
completedAt: new Date(),
|
||||
},
|
||||
// If the execution is an EXECUTE_JOB reason, we need to fail the run
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
executionDuration: {
|
||||
increment: durationInMs,
|
||||
},
|
||||
executionCount: {
|
||||
increment: 1,
|
||||
},
|
||||
tasks: {
|
||||
updateMany: {
|
||||
where: {
|
||||
status: {
|
||||
in: ["WAITING", "RUNNING", "PENDING"],
|
||||
},
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"deliverRunSubscriptions",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPROCESS": {
|
||||
// If the status is ABORTED, we need to fail the run
|
||||
if (status === "ABORTED") {
|
||||
await tx.jobRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: status === "TIMED_OUT" ? "CANCELED" : "ERRORED",
|
||||
completedAt: new Date(),
|
||||
status,
|
||||
output,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await tx.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "STARTED",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
forceYieldImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueRunExecutionV3(run, tx, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
await workerQueue.enqueue(
|
||||
"deliverRunSubscriptions",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #cancelExecution(run: FoundRun) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareNoOpTasksBloomFilter(possibleTasks: FoundTask[]): string {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { JobRun, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { PrismaClient, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { PerformRunExecutionV3Service, RunExecutionPriority } from "./performRunExecutionV3.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
|
||||
export class ResumeRunService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const run = await findRun(this.#prismaClient, id);
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (run.status) {
|
||||
case "ABORTED":
|
||||
case "CANCELED":
|
||||
case "FAILURE":
|
||||
case "INVALID_PAYLOAD":
|
||||
case "SUCCESS":
|
||||
case "TIMED_OUT":
|
||||
case "UNRESOLVED_AUTH": {
|
||||
return;
|
||||
}
|
||||
case "QUEUED": {
|
||||
await this.#resumeQueuedRun(run);
|
||||
break;
|
||||
}
|
||||
case "WAITING_TO_EXECUTE": {
|
||||
await this.#executeRun(run, "resume");
|
||||
break;
|
||||
}
|
||||
case "WAITING_TO_CONTINUE": {
|
||||
await this.#resumeWaitingToContinueRun(run);
|
||||
break;
|
||||
}
|
||||
case "STARTED": {
|
||||
await this.#resumeStartedRun(run);
|
||||
break;
|
||||
}
|
||||
case "PENDING":
|
||||
case "PREPROCESSING": {
|
||||
await this.#resumePendingRun(run);
|
||||
break;
|
||||
}
|
||||
case "EXECUTING": {
|
||||
throw new Error("Cannot resume a run that is currently executing");
|
||||
}
|
||||
case "WAITING_ON_CONNECTIONS": {
|
||||
throw new Error("Cannot resume a run that is waiting on connections");
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = run.status;
|
||||
throw new Error(`Non-exhaustive match for value: ${run.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #resumeQueuedRun(run: FoundRun) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
startedAt: run.startedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.#executeRun(run, "initial");
|
||||
}
|
||||
|
||||
async #resumeStartedRun(run: FoundRun) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
},
|
||||
});
|
||||
|
||||
await this.#executeRun(run, "initial");
|
||||
}
|
||||
|
||||
async #resumeWaitingToContinueRun(run: FoundRun) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "WAITING_TO_EXECUTE",
|
||||
},
|
||||
});
|
||||
|
||||
await this.#executeRun(run, "resume");
|
||||
}
|
||||
|
||||
async #resumePendingRun(run: FoundRun) {
|
||||
await this.#prismaClient.jobRun.update({
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
data: {
|
||||
status: "QUEUED",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.#executeRun(run, "initial");
|
||||
}
|
||||
|
||||
async #executeRun(run: FoundRun, priority: RunExecutionPriority) {
|
||||
await PerformRunExecutionV3Service.enqueue(run, priority, this.#prismaClient, {
|
||||
skipRetrying: run.version.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
}
|
||||
|
||||
static async enqueue(run: JobRun, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"resumeRun",
|
||||
{
|
||||
id: run.id,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: runAt ?? run.createdAt,
|
||||
jobKey: `run_resume:${run.id}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
static async dequeue(run: JobRun, tx: PrismaClientOrTransaction) {
|
||||
await workerQueue.dequeue(`run_resume:${run.id}`, {
|
||||
tx,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function findRun(prisma: PrismaClientOrTransaction, id: string) {
|
||||
return await prisma.jobRun.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
version: {
|
||||
include: {
|
||||
environment: true,
|
||||
concurrencyLimitGroup: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
RuntimeEnvironmentType,
|
||||
type ConnectionType,
|
||||
type Integration,
|
||||
type IntegrationConnection,
|
||||
} from "@trigger.dev/database";
|
||||
import type { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { enqueueRunExecutionV3 } from "~/models/jobRunExecution.server";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
|
||||
@@ -59,23 +59,24 @@ export class StartRunService {
|
||||
: undefined
|
||||
)
|
||||
.filter(Boolean);
|
||||
const lockId = jobIdToLockId(run.jobId);
|
||||
|
||||
const updateRun = async () => {
|
||||
if (run.preprocess) {
|
||||
// Start the jobRun and increment the jobCount
|
||||
return await this.#prismaClient.jobRun.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "PREPROCESSING",
|
||||
runConnections: {
|
||||
create: createRunConnections,
|
||||
},
|
||||
},
|
||||
await $transaction(
|
||||
this.#prismaClient,
|
||||
async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.jobCounter.upsert({
|
||||
where: { jobId: run.jobId },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
create: { jobId: run.jobId, lastNumber: 1 },
|
||||
select: { lastNumber: true },
|
||||
});
|
||||
} else {
|
||||
return await this.#prismaClient.jobRun.update({
|
||||
|
||||
const updatedRun = await this.#prismaClient.jobRun.update({
|
||||
where: { id },
|
||||
data: {
|
||||
number: counter.lastNumber,
|
||||
status: "QUEUED",
|
||||
queuedAt: new Date(),
|
||||
runConnections: {
|
||||
@@ -83,14 +84,11 @@ export class StartRunService {
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updatedRun = await updateRun();
|
||||
|
||||
await enqueueRunExecutionV3(updatedRun, this.#prismaClient, {
|
||||
skipRetrying: run.environment.type === RuntimeEnvironmentType.DEVELOPMENT,
|
||||
});
|
||||
await ResumeRunService.enqueue(updatedRun, tx);
|
||||
},
|
||||
{ timeout: 60000 }
|
||||
);
|
||||
}
|
||||
|
||||
async #handleMissingConnections(id: string, runConnectionsByKey: RunConnectionsByKey) {
|
||||
@@ -237,3 +235,8 @@ async function createRunConnections(tx: PrismaClientOrTransaction, run: FoundRun
|
||||
function hasMissingConnections(runConnectionsByKey: RunConnectionsByKey) {
|
||||
return Object.values(runConnectionsByKey).some((connection) => connection.result === "missing");
|
||||
}
|
||||
|
||||
function jobIdToLockId(jobId: string): number {
|
||||
// Convert jobId to a unique lock identifier
|
||||
return parseInt(createHash("sha256").update(jobId).digest("hex").slice(0, 8), 16);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from "zod";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { EndpointApi } from "../endpointApi.server";
|
||||
import { getSecretStore } from "../secrets/secretStore.server";
|
||||
|
||||
export class DeliverWebhookRequestService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string) {
|
||||
const requestDelivery = await this.#prismaClient.webhookRequestDelivery.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
webhook: {
|
||||
include: {
|
||||
integration: {
|
||||
include: {
|
||||
connections: true,
|
||||
},
|
||||
},
|
||||
httpEndpoint: {
|
||||
include: {
|
||||
secretReference: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
webhookEnvironment: {
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
endpoint: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestDelivery.webhookEnvironment.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { secretReference } = requestDelivery.webhook.httpEndpoint;
|
||||
|
||||
const secretStore = getSecretStore(secretReference.provider);
|
||||
|
||||
const secret = await secretStore.getSecret(
|
||||
z.object({
|
||||
secret: z.string(),
|
||||
}),
|
||||
secretReference.key
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(`Secret not found for ${requestDelivery.webhook.key}`);
|
||||
}
|
||||
|
||||
const clientApi = new EndpointApi(
|
||||
requestDelivery.webhookEnvironment.environment.apiKey,
|
||||
requestDelivery.endpoint.url
|
||||
);
|
||||
|
||||
const { response, verified, error } = await clientApi.deliverWebhookRequest({
|
||||
key: requestDelivery.webhook.key,
|
||||
secret: secret.secret,
|
||||
params: requestDelivery.webhook.params,
|
||||
request: {
|
||||
url: requestDelivery.url,
|
||||
method: requestDelivery.method,
|
||||
headers: requestDelivery.headers as Record<string, string>,
|
||||
rawBody: requestDelivery.body,
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.webhookRequestDelivery.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deliveredAt: new Date(),
|
||||
verified,
|
||||
error,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
|
||||
import { WebhookContextMetadata } from "@trigger.dev/core";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export class HandleWebhookRequestService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string, request: Request, metadata: WebhookContextMetadata) {
|
||||
const webhookEnvironment = await this.#prismaClient.webhookEnvironment.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
endpoint: true,
|
||||
environment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!webhookEnvironment) {
|
||||
return { status: 404 };
|
||||
}
|
||||
|
||||
if (!webhookEnvironment.active) {
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
const webhookRequest = await createHttpSourceRequest(request);
|
||||
|
||||
const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
|
||||
|
||||
await this.#prismaClient.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.webhookDeliveryCounter.upsert({
|
||||
where: { webhookId: webhookEnvironment.id },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
create: { webhookId: webhookEnvironment.id, lastNumber: 1 },
|
||||
select: { lastNumber: true },
|
||||
});
|
||||
|
||||
const delivery = await tx.webhookRequestDelivery.create({
|
||||
data: {
|
||||
number: counter.lastNumber,
|
||||
webhookId: webhookEnvironment.webhookId,
|
||||
webhookEnvironmentId: webhookEnvironment.id,
|
||||
endpointId: webhookEnvironment.endpointId,
|
||||
environmentId: webhookEnvironment.environmentId,
|
||||
url: webhookRequest.url,
|
||||
method: webhookRequest.method,
|
||||
headers: webhookRequest.headers,
|
||||
body: webhookRequest.rawBody,
|
||||
},
|
||||
});
|
||||
|
||||
await workerQueue.enqueue(
|
||||
"deliverWebhookRequest",
|
||||
{
|
||||
id: delivery.id,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
maxAttempts:
|
||||
webhookEnvironment.environment.type === RuntimeEnvironmentType.DEVELOPMENT
|
||||
? 1
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return { status: 200 };
|
||||
}
|
||||
}
|
||||
|
||||
function webhookIdToLockId(webhookId: string): number {
|
||||
// Convert webhookId to a unique lock identifier
|
||||
return parseInt(createHash("sha256").update(webhookId).digest("hex").slice(0, 8), 16);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TriggerSource, UpdateWebhookBody } from "@trigger.dev/core";
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export class UpdateWebhookService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
environment,
|
||||
payload,
|
||||
key,
|
||||
}: {
|
||||
environment: RuntimeEnvironment;
|
||||
payload: UpdateWebhookBody;
|
||||
key: string;
|
||||
}): Promise<TriggerSource> {
|
||||
const webhook = await this.#prismaClient.webhook.findUniqueOrThrow({
|
||||
where: {
|
||||
key_projectId: {
|
||||
key,
|
||||
projectId: environment.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.webhook.update({
|
||||
where: {
|
||||
key_projectId: {
|
||||
key,
|
||||
projectId: environment.projectId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
active: payload.active,
|
||||
webhookEnvironments: {
|
||||
update: {
|
||||
where: {
|
||||
environmentId_webhookId: {
|
||||
environmentId: environment.id,
|
||||
webhookId: webhook.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
active: payload.active,
|
||||
config: payload.active ? payload.config : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: webhook.id,
|
||||
key: webhook.key,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user