Compare commits
166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f9bd0d70 | |||
| a1860dbaae | |||
| d69e4e712d | |||
| 7fae67c47d | |||
| 9ae0ca64af | |||
| da69fa0613 | |||
| 336029b842 | |||
| 29edcd3df9 | |||
| a9ff32418e | |||
| 7358fcb891 | |||
| 28052daad9 | |||
| 364c8c5f7f | |||
| 4b3b418abb | |||
| 2e354d342c | |||
| dd879c8e4a | |||
| af485b9180 | |||
| a739ebaa88 | |||
| 3ebc2578e0 | |||
| 0b657b33f9 | |||
| 7ac942dc0e | |||
| 9b12016428 | |||
| d6b44de4ba | |||
| 17df4839d7 | |||
| e3f78178f7 | |||
| da90ee13c3 | |||
| 583da458ec | |||
| dcf95c4eb2 | |||
| 32cf5790cb | |||
| c272e38de2 | |||
| ca78ddc2c2 | |||
| 07ed8c346a | |||
| d4391f2e2d | |||
| 2c328ba93d | |||
| 68dbc07ee8 | |||
| 58448243af | |||
| 6c4047cf21 | |||
| 831860eace | |||
| db46f2a69a | |||
| 4dd6cf18dc | |||
| adf66d23b8 | |||
| 5c42831a60 | |||
| 26f5e7774d | |||
| f209a3b364 | |||
| a93b554f8b | |||
| 0f342cd1be | |||
| 7df2f85a1f | |||
| f14180d13c | |||
| ab6b9514cd | |||
| 98345d67d9 | |||
| 38f5a90399 | |||
| 1b2635ae4a | |||
| 129f023d11 | |||
| 795e637dec | |||
| 5238c424fc | |||
| 1bbd7e6dc3 | |||
| 3bb82ed9a5 | |||
| ff4ff869ab | |||
| 1dcee2b338 | |||
| 0a798446c8 | |||
| 209942a63d | |||
| 57a9b35870 | |||
| a16b65f666 | |||
| 5871745a92 | |||
| 87b5dfbf11 | |||
| 5af2003516 | |||
| 85ce729bf2 | |||
| 5701d1da42 | |||
| bc61d83764 | |||
| 5bf125be0d | |||
| babe1c0e54 | |||
| 1224fceb18 | |||
| 8277f4d249 | |||
| 73cb8839a5 | |||
| 5d0a731cf6 | |||
| 25a152517e | |||
| d1092fcd2c | |||
| 61d33ed1b8 | |||
| 7a51612fc8 | |||
| 4d380173c8 | |||
| 8880d8afe2 | |||
| ef037af457 | |||
| 390ac6101e | |||
| aa97bf4a52 | |||
| 86d6e102a4 | |||
| adf497bc3e | |||
| f7bf25f03f | |||
| f8cab96fa2 | |||
| 225614fea3 | |||
| 740b7b2385 | |||
| 26d223ab36 | |||
| 0c4fd9a631 | |||
| 5060eec01b | |||
| aaea064208 | |||
| 9346e4358d | |||
| 3aafd0480b | |||
| 771feb5395 | |||
| 03703af682 | |||
| 5359af1242 | |||
| dfa47b6201 | |||
| c01138b56e | |||
| 70fdedcea5 | |||
| 49ca4c4430 | |||
| 812b5afe6f | |||
| 922520cefb | |||
| 4bd1786ae5 | |||
| 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 |
@@ -14,6 +14,8 @@
|
||||
"ignore": [
|
||||
"webapp",
|
||||
"emails",
|
||||
"proxy",
|
||||
"yalt",
|
||||
"@trigger.dev/database"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
|
||||
@@ -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,84 @@
|
||||
name: "🐳 Publish Docker"
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
publish:
|
||||
strategy:
|
||||
fail-fast: true # when a job fails, all remaining ones will be cancelled
|
||||
matrix:
|
||||
runs-on: [buildjet-4vcpu-ubuntu-2204, buildjet-4vcpu-ubuntu-2204-arm]
|
||||
name: ${{matrix.runs-on}}
|
||||
runs-on: ${{matrix.runs-on}}
|
||||
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
|
||||
if [[ ${{matrix.runs-on}} == *-arm ]]; then
|
||||
IMAGE_TAG="${IMAGE_TAG}-arm"
|
||||
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: |
|
||||
LATEST=latest
|
||||
if [[ ${{matrix.runs-on}} == *-arm ]]; then
|
||||
LATEST="${LATEST}-arm"
|
||||
fi
|
||||
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",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,33 @@
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
|
||||
|
||||
export function DefinitionTip({
|
||||
content,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger>
|
||||
<span className="underline decoration-slate-600 decoration-dashed underline-offset-4 transition hover:decoration-slate-500">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="right" variant="dark" className="w-[16rem] min-w-[16rem]">
|
||||
<Header3 className="mb-1">{title}</Header3>
|
||||
{typeof content === "string" ? (
|
||||
<Paragraph variant="small">{content}</Paragraph>
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Fieldset } from "./primitives/Fieldset";
|
||||
import { FormButtons } from "./primitives/FormButtons";
|
||||
import { FormError } from "./primitives/FormError";
|
||||
import { Header2 } from "./primitives/Headers";
|
||||
import { InputGroup } from "./primitives/InputGroup";
|
||||
import { Label } from "./primitives/Label";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
@@ -20,8 +23,6 @@ import {
|
||||
} from "./primitives/Select";
|
||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from "./primitives/Sheet";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -55,19 +56,16 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild={true}>{button}</SheetTrigger>
|
||||
<SheetContent size="sm">
|
||||
<SheetHeader className="justify-between">Help & feedback</SheetHeader>
|
||||
<SheetBody>
|
||||
<SheetContent>
|
||||
<SheetHeader className="justify-between">How can we help?</SheetHeader>
|
||||
<SheetBody className="flex h-full flex-col justify-between">
|
||||
<DiscordBanner />
|
||||
<Paragraph variant="small" className="mb-4 border-t border-slate-800 pt-3">
|
||||
Or use this form to ask for help or give us feedback. We read every message and will get
|
||||
back to you as soon as we can.
|
||||
</Paragraph>
|
||||
<hr className="mb-3" />
|
||||
<Header2 className="mb-4">Send us a message</Header2>
|
||||
<Form method="post" action="/resources/feedback" {...form.props}>
|
||||
<Fieldset className="max-w-full">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>How can we help?</Label>
|
||||
<SelectGroup>
|
||||
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
|
||||
<SelectTrigger size="medium" width="full">
|
||||
@@ -90,14 +88,19 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<FormError id={message.errorId}>{message.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
className="w-full"
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium">
|
||||
Send
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Paragraph variant="small" className="w-full">
|
||||
We read every message and respond quickly.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
className="m-0 w-max"
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium">
|
||||
Send
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</SheetBody>
|
||||
@@ -109,7 +112,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
function DiscordBanner() {
|
||||
return (
|
||||
<a
|
||||
href="https://discord.gg/nkqV9xBYWy"
|
||||
href="https://trigger.dev/discord"
|
||||
target="_blank"
|
||||
className="group mb-4 flex w-full items-center justify-between rounded-md border border-slate-600 bg-gradient-to-br from-blue-400/30 to-indigo-400/50 p-4 transition hover:border-indigo-400"
|
||||
>
|
||||
@@ -120,13 +123,11 @@ function DiscordBanner() {
|
||||
<br />
|
||||
Discord community
|
||||
</h2>
|
||||
<Paragraph variant="small">
|
||||
<Paragraph variant="small/bright">
|
||||
Get help or answer questions from the Trigger.dev community.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
|
||||
</div>
|
||||
<ChevronRightIcon className="h-5 w-5 text-slate-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import gradientBackground from "~/assets/images/gradient-background.png";
|
||||
|
||||
export function PageGradient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="-mt-4 h-full w-full bg-cover bg-no-repeat pt-20"
|
||||
style={{ backgroundImage: `url("${gradientBackground}")` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Label,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid #1A2434",
|
||||
backgroundColor: "#0B1018",
|
||||
padding: "0.3rem 0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "#E2E8F0",
|
||||
};
|
||||
|
||||
type DataItem = { date: string; maxConcurrentRuns: number };
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
export function ConcurrentRunsChart({
|
||||
concurrentRunsLimit,
|
||||
data,
|
||||
hasConcurrencyData,
|
||||
}: {
|
||||
concurrentRunsLimit?: number;
|
||||
data: DataItem[];
|
||||
hasConcurrencyData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{!hasConcurrencyData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No concurrent Runs to show
|
||||
</Paragraph>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 10,
|
||||
}}
|
||||
className="-ml-8"
|
||||
>
|
||||
<XAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dataKey={(item: DataItem) => {
|
||||
if (!item.date) return "";
|
||||
const date = new Date(item.date);
|
||||
if (date.getDate() === 1) {
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
return `${date.getDate()}`;
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
|
||||
</XAxis>
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(value, data) => {
|
||||
const dateString = data.at(0)?.payload.date;
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return dateFormatter.format(new Date(dateString));
|
||||
}}
|
||||
/>
|
||||
{concurrentRunsLimit && (
|
||||
<ReferenceLine
|
||||
y={concurrentRunsLimit}
|
||||
stroke="#F43F5E"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={5}
|
||||
ifOverflow="extendDomain"
|
||||
className="text-xs"
|
||||
>
|
||||
<Label
|
||||
value="Concurrent Runs limit"
|
||||
offset={8}
|
||||
position="insideTopLeft"
|
||||
fill="#F43F5E"
|
||||
/>
|
||||
</ReferenceLine>
|
||||
)}
|
||||
<Line
|
||||
dataKey="maxConcurrentRuns"
|
||||
name="Concurrent runs"
|
||||
stroke="#16A34A"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid #1A2434",
|
||||
backgroundColor: "#0B1018",
|
||||
padding: "0.3rem 0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "#E2E8F0",
|
||||
};
|
||||
|
||||
type DataItem = { date: string; runs: number };
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
export function DailyRunsChart({
|
||||
data,
|
||||
hasDailyRunsData,
|
||||
}: {
|
||||
data: DataItem[];
|
||||
hasDailyRunsData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{!hasDailyRunsData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No daily Runs to show
|
||||
</Paragraph>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 10,
|
||||
}}
|
||||
className="-ml-8"
|
||||
>
|
||||
<XAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dataKey={(item: DataItem) => {
|
||||
if (!item.date) return "";
|
||||
const date = new Date(item.date);
|
||||
if (date.getDate() === 1) {
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
return `${date.getDate()}`;
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
|
||||
</XAxis>
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(value, data) => {
|
||||
const dateString = data.at(0)?.payload.date;
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return dateFormatter.format(new Date(dateString));
|
||||
}}
|
||||
/>
|
||||
<Line dataKey="runs" name="Runs" stroke="#16A34A" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
|
||||
const cappedPercentage = Math.min(percentage, 1);
|
||||
const widthProgress = useMotionValue(cappedPercentage * 100);
|
||||
const color = useTransform(
|
||||
widthProgress,
|
||||
[0, 74, 75, 95, 100],
|
||||
["#22C55E", "#22C55E", "#F59E0B", "#F43F5E", "#F43F5E"]
|
||||
);
|
||||
|
||||
const hasHitLimit = cappedPercentage >= 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-slate-900 bg-[#101722] p-2.5",
|
||||
hasHitLimit && "border-rose-800/60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpCircleIcon className="h-5 w-5 text-dimmed" />
|
||||
<Paragraph className="text-2sm text-bright">Free Plan</Paragraph>
|
||||
</div>
|
||||
<Link to={to} className="text-2sm text-indigo-500">
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative mt-3 h-1 rounded-full bg-[#0B1018]">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: cappedPercentage * 100 + "%" }}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
transition={{ duration: 1, type: "spring" }}
|
||||
className={cn("absolute left-0 top-0 h-full rounded-full")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import * as Slider from "@radix-ui/react-slider";
|
||||
import { Plans, estimate } from "@trigger.dev/billing";
|
||||
import { useCallback, useState } from "react";
|
||||
import { DefinitionTip } from "../DefinitionTooltip";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function PricingCalculator({ plans }: { plans: Plans }) {
|
||||
const [selectedConcurrencyIndex, setSelectedConcurrencyIndex] = useState(0);
|
||||
const concurrentRunTiers = [
|
||||
{ code: "free", upto: plans.free.concurrentRuns?.freeAllowance! },
|
||||
...(plans.paid.concurrentRuns?.pricing?.tiers ?? []),
|
||||
];
|
||||
const [runs, setRuns] = useState(0);
|
||||
const runBrackets = [
|
||||
...(plans.paid.runs?.pricing?.brackets.map((b, index, arr) => ({
|
||||
unitCost: b.unitCost,
|
||||
from: index === 0 ? 0 : arr[index - 1].upto! + 1,
|
||||
upto: b.upto ?? arr[index - 1].upto! * 10,
|
||||
})) ?? []),
|
||||
];
|
||||
|
||||
const result = estimate({
|
||||
usage: { runs, concurrent_runs: concurrentRunTiers[selectedConcurrencyIndex].upto - 1 },
|
||||
plans: [plans.free, plans.paid],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<ConcurrentRunsSlider
|
||||
options={concurrentRunTiers}
|
||||
selectedIndex={selectedConcurrencyIndex}
|
||||
setSelectedIndex={setSelectedConcurrencyIndex}
|
||||
cost={result?.cost.concurrentRunCost ?? 0}
|
||||
/>
|
||||
<RunsSlider
|
||||
brackets={runBrackets}
|
||||
runs={runs}
|
||||
setRuns={setRuns}
|
||||
cost={result?.cost.runsCost ?? 0}
|
||||
/>
|
||||
<GrandTotal cost={result?.cost.total ?? 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConcurrentRunsSlider({
|
||||
options,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
cost,
|
||||
}: {
|
||||
options: {
|
||||
code: string;
|
||||
upto: number;
|
||||
}[];
|
||||
selectedIndex: number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
cost: number;
|
||||
}) {
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2>
|
||||
<DefinitionTip
|
||||
content="The number of runs that can be executed at the same time. Get in touch if you need more than 100."
|
||||
title="Concurrent runs"
|
||||
>
|
||||
Concurrent runs
|
||||
</DefinitionTip>
|
||||
</Header2>
|
||||
<Header2>Up to {selectedOption.upto}</Header2>
|
||||
</div>
|
||||
<Slider.Root
|
||||
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
|
||||
value={[selectedIndex]}
|
||||
onValueChange={(value) => setSelectedIndex(value[0])}
|
||||
max={options.length - 1}
|
||||
step={1}
|
||||
>
|
||||
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
|
||||
aria-label="Concurrent runs slider"
|
||||
/>
|
||||
</Slider.Root>
|
||||
<div className="ml-1.5 flex w-[99.85%] items-center justify-between">
|
||||
{options.map((tier, i) => {
|
||||
return (
|
||||
<Paragraph variant="extra-small" className="text-slate-600" key={i}>
|
||||
{tier.upto}
|
||||
</Paragraph>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full items-start">
|
||||
<span className="ml-6 text-dimmed">=</span>
|
||||
<Header2 className="min-w-[8ch] text-right text-dimmed">
|
||||
{formatCurrency(cost, true)}
|
||||
</Header2>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6 border-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const runIncrements = 10_000;
|
||||
function RunsSlider({
|
||||
brackets,
|
||||
runs,
|
||||
setRuns,
|
||||
cost,
|
||||
}: {
|
||||
brackets: {
|
||||
from: number;
|
||||
upto: number;
|
||||
unitCost: number;
|
||||
}[];
|
||||
runs: number;
|
||||
setRuns: (value: number) => void;
|
||||
cost: number;
|
||||
}) {
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
const updateRuns = useCallback((value: number) => {
|
||||
setValue(value);
|
||||
const r = calculateRuns(value / runIncrements, brackets);
|
||||
setRuns(r);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2>
|
||||
<DefinitionTip content="A single execution of a Job." title="Runs">
|
||||
Runs
|
||||
</DefinitionTip>
|
||||
</Header2>
|
||||
<Header2>{formatNumberCompact(runs)}</Header2>
|
||||
</div>
|
||||
<Slider.Root
|
||||
className="relative mb-2 mt-4 flex h-5 w-full touch-none select-none items-center"
|
||||
value={[value]}
|
||||
onValueChange={(value) => updateRuns(value[0])}
|
||||
max={runIncrements}
|
||||
step={1}
|
||||
>
|
||||
<Slider.Track className="relative h-[8px] grow rounded-full bg-slate-850">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-indigo-500" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
className="block h-5 w-5 rounded-full border-4 border-indigo-500 bg-slate-850 shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] transition hover:border-indigo-400 hover:bg-slate-800 focus:shadow-[0_1px_3px_4px_rgb(0_0_0_/_0.2),_0_1px_2px_-1px_rgb(0_0_0_/_0.1)] focus:outline-none"
|
||||
aria-label="Concurrent runs slider"
|
||||
/>
|
||||
</Slider.Root>
|
||||
<div className="relative w-full">
|
||||
{brackets.map((bracket, i, arr) => {
|
||||
const percentagePerBracket = 1 / arr.length;
|
||||
return (
|
||||
<SliderMarker
|
||||
key={i}
|
||||
percentage={(i / (arr.length - 1)) * percentagePerBracket * (arr.length - 1)}
|
||||
alignment={i === 0 ? "left" : "center"}
|
||||
text={formatNumberCompact(bracket.from)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<SliderMarker
|
||||
percentage={1}
|
||||
alignment={"right"}
|
||||
text={formatNumberCompact(brackets[brackets.length - 1].upto)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full items-start">
|
||||
<span className="ml-6 text-dimmed">=</span>
|
||||
<Header2 className="min-w-[8ch] text-right text-dimmed">
|
||||
{formatCurrency(cost, true)}
|
||||
</Header2>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6 border-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function calculateRuns(percentage: number, brackets: { from: number; upto: number }[]) {
|
||||
//first we find which bucket we're in
|
||||
const buckets = brackets.length;
|
||||
const bucket = Math.min(Math.floor(percentage * buckets), brackets.length - 1);
|
||||
const percentagePerBucket = 1 / buckets;
|
||||
|
||||
//relevant bracket
|
||||
let bracket = brackets[bucket];
|
||||
const from = bracket.from;
|
||||
const upto = bracket.upto;
|
||||
|
||||
//how far as we into the bracket
|
||||
const percentageIntoBracket = (percentage - bucket * percentagePerBucket) / percentagePerBucket;
|
||||
|
||||
//calculate the runs
|
||||
const runs = Math.floor(from + (upto - from) * percentageIntoBracket);
|
||||
return runs;
|
||||
}
|
||||
|
||||
function GrandTotal({ cost }: { cost: number }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<Header2>Total monthly estimate</Header2>
|
||||
<Header2>{formatCurrency(cost, true)}</Header2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SliderMarker({
|
||||
percentage,
|
||||
alignment,
|
||||
text,
|
||||
}: {
|
||||
percentage: number;
|
||||
alignment: "left" | "center" | "right";
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute top-0 h-4"
|
||||
style={{
|
||||
left: `${percentage * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute flex items-center",
|
||||
alignment === "left"
|
||||
? "left-0 justify-start"
|
||||
: alignment === "center"
|
||||
? "-translate-x-1/2 justify-center"
|
||||
: "justify-middle right-0"
|
||||
)}
|
||||
>
|
||||
<Paragraph variant="extra-small" className="text-slate-600">
|
||||
{text}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActiveSubscription, Plan, Plans, SetPlanBodySchema } from "@trigger.dev/billing";
|
||||
import { useState } from "react";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { DefinitionTip } from "../DefinitionTooltip";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { RunsVolumeDiscountTable } from "./RunsVolumeDiscountTable";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
const pricingDefinitions = {
|
||||
concurrentRuns: {
|
||||
title: "Concurrent runs",
|
||||
content: "The number of runs that can be executed at the same time.",
|
||||
},
|
||||
jobRuns: {
|
||||
title: "Job runs",
|
||||
content: "A single execution of a job.",
|
||||
},
|
||||
jobs: {
|
||||
title: "Jobs",
|
||||
content: "A durable function that can be executed on a schedule or in response to an event.",
|
||||
},
|
||||
tasks: {
|
||||
title: "Tasks",
|
||||
content: "The individual building blocks of a job run.",
|
||||
},
|
||||
events: {
|
||||
title: "Events",
|
||||
content: "Events trigger jobs to start running.",
|
||||
},
|
||||
integrations: {
|
||||
title: "Integrations",
|
||||
content: "Easily subscribe to webhooks and perform actions using APIs.",
|
||||
},
|
||||
};
|
||||
|
||||
export function PricingTiers({
|
||||
organizationSlug,
|
||||
plans,
|
||||
className,
|
||||
showActionText = true,
|
||||
freeButtonPath,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
plans: Plans;
|
||||
className?: string;
|
||||
showActionText?: boolean;
|
||||
freeButtonPath?: string;
|
||||
}) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
//if they've canceled, we set the subscription to undefined so they can re-upgrade
|
||||
let currentSubscription = currentPlan?.subscription;
|
||||
if (currentPlan?.subscription?.canceledAt) {
|
||||
currentSubscription = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-full flex-col items-start justify-center gap-4 md:flex-row",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TierFree
|
||||
plan={plans.free}
|
||||
currentSubscription={currentSubscription}
|
||||
organizationSlug={organizationSlug}
|
||||
showActionText={showActionText}
|
||||
buttonPath={freeButtonPath}
|
||||
/>
|
||||
<TierPro
|
||||
plan={plans.paid}
|
||||
currentSubscription={currentSubscription}
|
||||
organizationSlug={organizationSlug}
|
||||
showActionText={showActionText}
|
||||
/>
|
||||
<TierEnterprise />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TierFree({
|
||||
plan,
|
||||
organizationSlug,
|
||||
showActionText,
|
||||
currentSubscription,
|
||||
buttonPath,
|
||||
}: {
|
||||
plan: Plan;
|
||||
organizationSlug: string;
|
||||
showActionText: boolean;
|
||||
currentSubscription?: ActiveSubscription;
|
||||
buttonPath?: string;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form] = useForm({
|
||||
id: "subscribe",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: SetPlanBodySchema });
|
||||
},
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading =
|
||||
(navigation.state === "submitting" || navigation.state === "loading") &&
|
||||
navigation.formData?.get("type") === "free";
|
||||
|
||||
const isCurrentPlan =
|
||||
currentSubscription?.isPaying === undefined || currentSubscription?.isPaying === false;
|
||||
|
||||
let actionText = "Select plan";
|
||||
|
||||
if (showActionText) {
|
||||
if (isCurrentPlan) {
|
||||
actionText = "Current Plan";
|
||||
} else {
|
||||
actionText = "Downgrade";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TierContainer>
|
||||
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
|
||||
<Header title={plan.title} cost={0} />
|
||||
<TierLimit>
|
||||
Up to {plan.concurrentRuns?.freeAllowance}{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.concurrentRuns.title}
|
||||
content={pricingDefinitions.concurrentRuns.content}
|
||||
>
|
||||
{pricingDefinitions.concurrentRuns.title}
|
||||
</DefinitionTip>
|
||||
</TierLimit>
|
||||
<input type="hidden" name="type" value="free" />
|
||||
<div className="py-6">
|
||||
{buttonPath ? (
|
||||
<LinkButton
|
||||
variant="secondary/large"
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
to={buttonPath}
|
||||
>
|
||||
{actionText}
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary/large"
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
disabled={isLoading || isCurrentPlan}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner color="white" />
|
||||
Updating plan
|
||||
</div>
|
||||
) : (
|
||||
actionText
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<FeatureItem checked>
|
||||
Up to {plan.runs?.freeAllowance ? formatNumberCompact(plan.runs.freeAllowance) : ""}{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobRuns.title}
|
||||
content={pricingDefinitions.jobRuns.content}
|
||||
>
|
||||
{pricingDefinitions.jobRuns.title}
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobs.title}
|
||||
content={pricingDefinitions.jobs.content}
|
||||
>
|
||||
jobs
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.tasks.title}
|
||||
content={pricingDefinitions.tasks.content}
|
||||
>
|
||||
tasks
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.events.title}
|
||||
content={pricingDefinitions.events.content}
|
||||
>
|
||||
events
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>Unlimited team members</FeatureItem>
|
||||
<FeatureItem checked>24 hour log retention</FeatureItem>
|
||||
<FeatureItem checked>Community support</FeatureItem>
|
||||
<FeatureItem>Custom integrations</FeatureItem>
|
||||
<FeatureItem>Role-based access control</FeatureItem>
|
||||
<FeatureItem>SSO</FeatureItem>
|
||||
<FeatureItem>On-prem option</FeatureItem>
|
||||
</ul>
|
||||
</Form>
|
||||
</TierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function TierPro({
|
||||
plan,
|
||||
organizationSlug,
|
||||
showActionText,
|
||||
currentSubscription,
|
||||
}: {
|
||||
plan: Plan;
|
||||
organizationSlug: string;
|
||||
showActionText: boolean;
|
||||
currentSubscription?: ActiveSubscription;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form] = useForm({
|
||||
id: "subscribe",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: SetPlanBodySchema });
|
||||
},
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading =
|
||||
(navigation.state === "submitting" || navigation.state === "loading") &&
|
||||
navigation.formData?.get("planCode") === plan.code;
|
||||
|
||||
const currentConcurrencyTier = currentSubscription?.plan.concurrentRuns.pricing?.code;
|
||||
const [concurrentBracketCode, setConcurrentBracketCode] = useState(
|
||||
currentConcurrencyTier ?? plan.concurrentRuns?.pricing?.tiers[0].code
|
||||
);
|
||||
|
||||
const concurrencyTiers = plan.concurrentRuns?.pricing?.tiers ?? [];
|
||||
const selectedTier = concurrencyTiers.find((c) => c.code === concurrentBracketCode);
|
||||
|
||||
const freeRunCount = plan.runs?.pricing?.brackets[0].upto ?? 0;
|
||||
const mostExpensiveRunCost = plan.runs?.pricing?.brackets[1]?.unitCost ?? 0;
|
||||
|
||||
const isCurrentPlan = currentConcurrencyTier === concurrentBracketCode;
|
||||
|
||||
let actionText = "Select plan";
|
||||
|
||||
if (showActionText) {
|
||||
if (isCurrentPlan) {
|
||||
actionText = "Current Plan";
|
||||
} else {
|
||||
const currentTierIndex = concurrencyTiers.findIndex((c) => c.code === currentConcurrencyTier);
|
||||
const selectedTierIndex = concurrencyTiers.findIndex((c) => c.code === concurrentBracketCode);
|
||||
actionText = currentTierIndex < selectedTierIndex ? "Upgrade" : "Downgrade";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TierContainer isHighlighted>
|
||||
<Form action={`/resources/${organizationSlug}/subscribe`} method="post" {...form.props}>
|
||||
<Header title={plan.title} isHighlighted cost={selectedTier?.tierCost} />
|
||||
|
||||
<div className="mb-2 mt-6 font-sans text-sm font-normal text-bright">
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.concurrentRuns.title}
|
||||
content={pricingDefinitions.concurrentRuns.content}
|
||||
>
|
||||
{pricingDefinitions.concurrentRuns.title}
|
||||
</DefinitionTip>
|
||||
</div>
|
||||
<input type="hidden" name="type" value="paid" />
|
||||
<input type="hidden" name="planCode" value={plan.code} />
|
||||
<SegmentedControl
|
||||
name="concurrentRunBracket"
|
||||
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
|
||||
fullWidth
|
||||
value={concurrentBracketCode}
|
||||
onChange={(v) => setConcurrentBracketCode(v)}
|
||||
/>
|
||||
<div className="py-6">
|
||||
<Button
|
||||
variant="primary/large"
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
type="submit"
|
||||
disabled={isLoading || isCurrentPlan}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner color="white" />
|
||||
Updating plan
|
||||
</div>
|
||||
) : (
|
||||
actionText
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<FeatureItem checked>
|
||||
Includes {freeRunCount ? formatNumberCompact(freeRunCount) : ""}{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobRuns.title}
|
||||
content={pricingDefinitions.jobRuns.content}
|
||||
>
|
||||
{pricingDefinitions.jobRuns.title}
|
||||
</DefinitionTip>
|
||||
, then{" "}
|
||||
<DefinitionTip
|
||||
title="Runs volume discount"
|
||||
content={
|
||||
<RunsVolumeDiscountTable hideHeader brackets={plan.runs?.pricing?.brackets ?? []} />
|
||||
}
|
||||
>
|
||||
{"<"} ${(mostExpensiveRunCost * 1000).toFixed(2)}/1K runs
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobs.title}
|
||||
content={pricingDefinitions.jobs.content}
|
||||
>
|
||||
jobs
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.tasks.title}
|
||||
content={pricingDefinitions.tasks.content}
|
||||
>
|
||||
tasks
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.events.title}
|
||||
content={pricingDefinitions.events.content}
|
||||
>
|
||||
events
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>Unlimited team members</FeatureItem>
|
||||
<FeatureItem checked>7 day log retention</FeatureItem>
|
||||
<FeatureItem checked>Dedicated Slack support</FeatureItem>
|
||||
<FeatureItem>Custom integrations</FeatureItem>
|
||||
<FeatureItem>Role-based access control</FeatureItem>
|
||||
<FeatureItem>SSO</FeatureItem>
|
||||
<FeatureItem>On-prem option</FeatureItem>
|
||||
</ul>
|
||||
</Form>
|
||||
</TierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function TierEnterprise() {
|
||||
return (
|
||||
<TierContainer>
|
||||
<Header title="Enterprise" />
|
||||
<TierLimit>
|
||||
Flexible{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.concurrentRuns.title}
|
||||
content={pricingDefinitions.concurrentRuns.content}
|
||||
>
|
||||
{pricingDefinitions.concurrentRuns.title}
|
||||
</DefinitionTip>
|
||||
</TierLimit>
|
||||
<div className="py-6">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="secondary/large" fullWidth className="text-md font-medium">
|
||||
Contact us
|
||||
</Button>
|
||||
}
|
||||
defaultValue="enterprise"
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<FeatureItem checked>
|
||||
Flexible{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobRuns.title}
|
||||
content={pricingDefinitions.jobRuns.content}
|
||||
>
|
||||
{pricingDefinitions.jobRuns.title}
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.jobs.title}
|
||||
content={pricingDefinitions.jobs.content}
|
||||
>
|
||||
jobs
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.tasks.title}
|
||||
content={pricingDefinitions.tasks.content}
|
||||
>
|
||||
tasks
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Unlimited{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.events.title}
|
||||
content={pricingDefinitions.events.content}
|
||||
>
|
||||
events
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>Unlimited team members</FeatureItem>
|
||||
<FeatureItem checked>30 day log retention</FeatureItem>
|
||||
<FeatureItem checked>Priority support</FeatureItem>
|
||||
<FeatureItem checked>
|
||||
Custom{" "}
|
||||
<DefinitionTip
|
||||
title={pricingDefinitions.integrations.title}
|
||||
content={pricingDefinitions.integrations.content}
|
||||
>
|
||||
{pricingDefinitions.integrations.title}
|
||||
</DefinitionTip>
|
||||
</FeatureItem>
|
||||
<FeatureItem checked>Role-based access control</FeatureItem>
|
||||
<FeatureItem checked>SSO</FeatureItem>
|
||||
<FeatureItem checked>On-prem option</FeatureItem>
|
||||
</ul>
|
||||
</TierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TierContainer({
|
||||
children,
|
||||
isHighlighted,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isHighlighted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-[16rem] flex-col rounded-md border p-6",
|
||||
isHighlighted ? "border-indigo-500" : "border-border"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
title,
|
||||
cost: flatCost,
|
||||
isHighlighted,
|
||||
}: {
|
||||
title: string;
|
||||
cost?: number;
|
||||
isHighlighted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className={cn("text-xl font-medium", isHighlighted ? "text-indigo-500" : "text-dimmed")}>
|
||||
{title}
|
||||
</h2>
|
||||
{flatCost === 0 || flatCost ? (
|
||||
<h3 className="text-4xl font-medium">
|
||||
${flatCost}
|
||||
<span className="text-2sm font-normal tracking-wide text-dimmed">/month</span>
|
||||
</h3>
|
||||
) : (
|
||||
<h2 className="text-4xl font-medium">Custom</h2>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierLimit({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<hr className="my-[1.9rem]" />
|
||||
<div className="mb-[0.6rem] mt-6 font-sans text-sm font-normal text-bright">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureItem({ checked, children }: { checked?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className="flex items-center gap-2">
|
||||
{checked ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XMarkIcon className="h-4 w-4 text-slate-500" />
|
||||
)}
|
||||
<div className={cn("font-sans text-sm font-normal", checked ? "text-bright" : "text-dimmed")}>
|
||||
{children}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { RunPriceBracket } from "@trigger.dev/billing";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
|
||||
export function RunsVolumeDiscountTable({
|
||||
className,
|
||||
hideHeader = false,
|
||||
brackets,
|
||||
}: {
|
||||
className?: string;
|
||||
hideHeader?: boolean;
|
||||
brackets: RunPriceBracket[];
|
||||
}) {
|
||||
const runsVolumeDiscountRow =
|
||||
"flex justify-between whitespace-nowrap border-b gap-16 border-border last:pb-0 last:border-none py-2";
|
||||
|
||||
const bracketData = bracketInfo(brackets);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{hideHeader ? null : <Header2 className="mb-2">Runs volume discount</Header2>}
|
||||
<ul>
|
||||
{bracketData.map((bracket, index) => (
|
||||
<li key={index} className={runsVolumeDiscountRow}>
|
||||
<Paragraph variant="small">{bracket.range}</Paragraph>
|
||||
<Paragraph variant="small">{bracket.costLabel}</Paragraph>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function bracketInfo(brackets: RunPriceBracket[]) {
|
||||
return brackets.map((bracket, index) => {
|
||||
const { upto, unitCost } = bracket;
|
||||
|
||||
if (index === 0) {
|
||||
return {
|
||||
range: `First ${formatNumberCompact(upto!)}/mo`,
|
||||
costLabel: "Free",
|
||||
};
|
||||
}
|
||||
|
||||
const from = brackets[index - 1].upto;
|
||||
const fromFormatted = formatNumberCompact(from!);
|
||||
const toFormatted = upto ? formatNumberCompact(upto) : undefined;
|
||||
|
||||
const costLabel = `$${(unitCost * 1000).toFixed(2)}/1,000`;
|
||||
|
||||
if (!upto) {
|
||||
return {
|
||||
range: `${fromFormatted} +`,
|
||||
costLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
range: `${fromFormatted}–${toFormatted}`,
|
||||
costLabel,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { plansPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
|
||||
type UpgradePromptProps = {
|
||||
organization: MatchedOrganization;
|
||||
};
|
||||
|
||||
export function UpgradePrompt({ organization }: UpgradePromptProps) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
if (!currentPlan || !currentPlan.usage.exceededRunCount || !currentPlan.usage.runCountCap) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout variant="error" className="flex h-full items-center rounded-none px-1 py-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Paragraph variant="extra-small" className="text-white">
|
||||
{organization.runsEnabled
|
||||
? `You have exceeded the monthly ${formatNumberCompact(
|
||||
currentPlan.usage.runCountCap
|
||||
)} runs
|
||||
limit`
|
||||
: `No runs are executing because you have exceeded the free limit`}
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type UsageBarProps = {
|
||||
numberOfCurrentRuns: number;
|
||||
billingLimit?: number;
|
||||
tierRunLimit?: number;
|
||||
projectedRuns: number;
|
||||
subscribedToPaidTier?: boolean;
|
||||
};
|
||||
|
||||
export function UsageBar({
|
||||
numberOfCurrentRuns,
|
||||
billingLimit,
|
||||
tierRunLimit,
|
||||
projectedRuns,
|
||||
subscribedToPaidTier = false,
|
||||
}: UsageBarProps) {
|
||||
const getLargestNumber = Math.max(
|
||||
numberOfCurrentRuns,
|
||||
tierRunLimit ?? -Infinity,
|
||||
projectedRuns,
|
||||
billingLimit ?? -Infinity
|
||||
);
|
||||
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
|
||||
const maxRange = Math.round(getLargestNumber * 1.1);
|
||||
const tierRunLimitPercentage = tierRunLimit ? Math.round((tierRunLimit / maxRange) * 100) : 0;
|
||||
const projectedRunsPercentage = Math.round((projectedRuns / maxRange) * 100);
|
||||
const billingLimitPercentage =
|
||||
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
|
||||
const usagePercentage = Math.round((numberOfCurrentRuns / maxRange) * 100);
|
||||
|
||||
//cap the usagePercentage to the freeRunLimitPercentage
|
||||
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
|
||||
|
||||
return (
|
||||
<div className="h-fit w-full py-16">
|
||||
<div className="relative h-3 w-full rounded-sm bg-slate-800">
|
||||
{billingLimit && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: billingLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${billingLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Billing limit:"
|
||||
value={formatNumberCompact(billingLimit)}
|
||||
position="bottomRow2"
|
||||
percentage={billingLimitPercentage}
|
||||
tooltipContent={`Billing limit: ${formatNumberCompact(billingLimit)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{tierRunLimit && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: tierRunLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${tierRunLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-900/50"
|
||||
>
|
||||
<Legend
|
||||
text={`${subscribedToPaidTier ? "Included free:" : "Free tier limit:"}`}
|
||||
value={formatNumberCompact(tierRunLimit)}
|
||||
position="bottomRow1"
|
||||
percentage={tierRunLimitPercentage}
|
||||
tooltipContent={`${
|
||||
subscribedToPaidTier
|
||||
? `Runs included free: ${formatNumberCompact(tierRunLimit)}`
|
||||
: `Free tier runs limit: ${formatNumberCompact(tierRunLimit)}`
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{projectedRuns !== 0 && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: projectedRunsPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${projectedRunsPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Projected:"
|
||||
value={formatNumberCompact(projectedRuns)}
|
||||
position="topRow2"
|
||||
percentage={projectedRunsPercentage}
|
||||
tooltipContent={`Projected runs: ${formatNumberCompact(projectedRuns)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: usagePercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
className={cn(
|
||||
"absolute h-3 rounded-l-sm",
|
||||
subscribedToPaidTier ? "bg-green-600" : "bg-rose-600"
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
text="Current:"
|
||||
value={formatNumberCompact(numberOfCurrentRuns)}
|
||||
position="topRow1"
|
||||
percentage={usagePercentage}
|
||||
tooltipContent={`Current run count: ${formatNumberCompact(numberOfCurrentRuns)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: usageCappedToLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usageCappedToLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const positions = {
|
||||
topRow1: "bottom-0 h-9",
|
||||
topRow2: "bottom-0 h-14",
|
||||
bottomRow1: "top-0 h-9 items-end",
|
||||
bottomRow2: "top-0 h-14 items-end",
|
||||
};
|
||||
|
||||
type LegendProps = {
|
||||
text: string;
|
||||
value: number | string;
|
||||
percentage: number;
|
||||
position: keyof typeof positions;
|
||||
tooltipContent: string;
|
||||
};
|
||||
|
||||
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
|
||||
const flipLegendPositionValue = 80;
|
||||
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full z-10 flex border-slate-400",
|
||||
positions[position],
|
||||
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background px-1.5 text-xs text-dimmed">
|
||||
{text}
|
||||
<span className="ml-1 text-bright">{value}</span>
|
||||
</Paragraph>
|
||||
}
|
||||
variant="dark"
|
||||
side="top"
|
||||
content={tooltipContent}
|
||||
className="z-50 h-fit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,11 @@ type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
userName,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
userName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -18,19 +20,19 @@ export function EnvironmentLabel({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{environmentTitle(environment)}
|
||||
{environmentTitle(environment, userName)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment) {
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Dev";
|
||||
return username ? `Dev: ${username}` : "Dev";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import {
|
||||
RunPanel,
|
||||
RunPanelBody,
|
||||
RunPanelDivider,
|
||||
RunPanelIconProperty,
|
||||
RunPanelIconSection,
|
||||
} from "~/components/run/RunCard";
|
||||
import { Event } from "~/presenters/EventPresenter.server";
|
||||
|
||||
export function EventDetail({ event }: { event: Event }) {
|
||||
const { id, name, payload, context, timestamp, deliveredAt } = event;
|
||||
|
||||
return (
|
||||
<RunPanel selected={false}>
|
||||
<RunPanelBody>
|
||||
<RunPanelIconSection>
|
||||
<RunPanelIconProperty
|
||||
icon="calendar"
|
||||
label="Created"
|
||||
value={<DateTime date={timestamp} />}
|
||||
/>
|
||||
{deliveredAt && (
|
||||
<RunPanelIconProperty
|
||||
icon="flag"
|
||||
label="Delivered"
|
||||
value={<DateTime date={deliveredAt} />}
|
||||
/>
|
||||
)}
|
||||
<RunPanelIconProperty icon="id" label="Event name" value={name} />
|
||||
<RunPanelIconProperty icon="account" label="Event ID" value={id} />
|
||||
</RunPanelIconSection>
|
||||
<RunPanelDivider />
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Header3>Payload</Header3>
|
||||
<CodeBlock code={payload} />
|
||||
<Header3>Context</Header3>
|
||||
<CodeBlock code={context} />
|
||||
</div>
|
||||
</RunPanelBody>
|
||||
</RunPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { DirectionSchema, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
|
||||
export const EventListSearchSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: DirectionSchema.optional(),
|
||||
environment: FilterableEnvironment.optional(),
|
||||
from: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
import { EventListSearchSchema } from "./EventStatuses";
|
||||
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
export function EventsFilters() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environment, from, to } = EventListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
|
||||
if (range.from) {
|
||||
searchParams.set("from", range.from.toString());
|
||||
} else {
|
||||
searchParams.delete("from");
|
||||
}
|
||||
|
||||
if (range.to) {
|
||||
searchParams.set("to", range.to.toString());
|
||||
} else {
|
||||
searchParams.delete("to");
|
||||
}
|
||||
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = (value: FilterableEnvironment | "ALL") => {
|
||||
handleFilterChange("environment", value === "ALL" ? undefined : value);
|
||||
};
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("status");
|
||||
searchParams.delete("environment");
|
||||
searchParams.delete("from");
|
||||
searchParams.delete("to");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between gap-x-2">
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environment ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{environmentKeys.map((env) => (
|
||||
<SelectItem key={env} value={env}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={{ type: env }} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { RuntimeEnvironmentType, User } from "@trigger.dev/database";
|
||||
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,
|
||||
TableCellChevron,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "../primitives/Table";
|
||||
|
||||
type EventTableItem = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
};
|
||||
createdAt: Date | null;
|
||||
isTest: boolean;
|
||||
deliverAt: Date | null;
|
||||
deliveredAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
runs: number;
|
||||
};
|
||||
|
||||
type EventsTableProps = {
|
||||
total: number;
|
||||
hasFilters: boolean;
|
||||
events: EventTableItem[];
|
||||
isLoading?: boolean;
|
||||
eventsParentPath: string;
|
||||
currentUser: User;
|
||||
};
|
||||
|
||||
export function EventsTable({
|
||||
total,
|
||||
hasFilters,
|
||||
events,
|
||||
isLoading = false,
|
||||
eventsParentPath,
|
||||
currentUser,
|
||||
}: EventsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Event</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Received Time</TableHeaderCell>
|
||||
<TableHeaderCell>Delivery Time</TableHeaderCell>
|
||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
||||
<TableHeaderCell>Canceled Time</TableHeaderCell>
|
||||
<TableHeaderCell>Test</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to page</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<NoEvents title="No events found" />
|
||||
</TableBlankRow>
|
||||
) : events.length === 0 ? (
|
||||
<TableBlankRow colSpan={9}>
|
||||
<NoEvents title="No events match your filters" />
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
events.map((event) => {
|
||||
const path = `${eventsParentPath}/events/${event.id}`;
|
||||
const usernameForEnv =
|
||||
currentUser.id !== event.environment.userId ? event.environment.userName : undefined;
|
||||
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell to={path}>{typeof event.name === "string" ? event.name : "-"}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel environment={event.environment} userName={usernameForEnv} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.createdAt ? <DateTime date={event.createdAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.deliverAt ? <DateTime date={event.deliverAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.deliveredAt ? <DateTime date={event.deliveredAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.cancelledAt ? <DateTime date={event.cancelledAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{event.isTest ? (
|
||||
<CheckIcon className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<StopIcon className="h-4 w-4 text-slate-850" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{event.runs}</TableCell>
|
||||
<TableCellChevron to={path} isSticky />
|
||||
</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 NoEvents({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export function ConnectToOAuthForm({
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
disabled={requiresCustomOAuthApp}
|
||||
readOnly={requiresCustomOAuthApp}
|
||||
onChange={(checked) => setUseMyOAuthApp(checked)}
|
||||
{...conform.input(hasCustomClient, { type: "checkbox" })}
|
||||
defaultChecked={requiresCustomOAuthApp}
|
||||
@@ -135,8 +135,9 @@ export function ConnectToOAuthForm({
|
||||
{useMyOAuthApp && (
|
||||
<div className="ml-6 mt-2">
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
Set the callback url to <CodeBlock code={callbackUrl} showLineNumbers={false} />
|
||||
Set the callback url to
|
||||
</Paragraph>
|
||||
<CodeBlock code={callbackUrl} showLineNumbers={false} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<InputGroup fullWidth>
|
||||
|
||||
@@ -3,37 +3,10 @@ import { CodeExample } from "~/routes/resources.codeexample";
|
||||
import { Api } from "~/services/externalApis/apis.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { Header1, Header2, Header3 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
|
||||
const fallbackExamples = [
|
||||
{
|
||||
title: "Post to Slack when meetings are booked or cancelled.",
|
||||
slug: "cal-slack-meeting-alert",
|
||||
version: "1.0.0",
|
||||
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/cal-http-endpoint.ts",
|
||||
},
|
||||
{
|
||||
title: "Translate some text with DeepL.",
|
||||
slug: "translate-text-with-deepl",
|
||||
version: "1.0.0",
|
||||
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/deepl.ts",
|
||||
},
|
||||
{
|
||||
title: "Create a Discord bot and send a message to a channel.",
|
||||
slug: "discord-bot-send-message",
|
||||
version: "1.0.0",
|
||||
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/discord.ts",
|
||||
},
|
||||
{
|
||||
title: "Retrieve a Notion page by ID.",
|
||||
slug: "retrieve-notion-page",
|
||||
version: "1.0.0",
|
||||
codeUrl: "https://github.com/triggerdotdev/api-reference/raw/main/src/notion.ts",
|
||||
},
|
||||
];
|
||||
|
||||
export function CustomHelp({ api }: { api: Api }) {
|
||||
const [selectedExample, setSelectedExample] = useState(0);
|
||||
|
||||
@@ -43,7 +16,7 @@ export function CustomHelp({ api }: { api: Api }) {
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Header1 className="mb-2">Using an API with an SDK or requests</Header1>
|
||||
<Header1 className="mb-2">Using {api.name} with an SDK or requests</Header1>
|
||||
<Paragraph spacing>
|
||||
You can use Trigger.dev with any existing Node SDK or even just using fetch. You can
|
||||
subscribe to any API with{" "}
|
||||
@@ -87,9 +60,13 @@ export function CustomHelp({ api }: { api: Api }) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header2 className="mb-2">Example code using fetch / an existing SDK</Header2>
|
||||
<Header2 className="mb-2">Getting started with {api.name}</Header2>
|
||||
<Paragraph spacing className="mb-4">
|
||||
We recommend searching for the official {api.name} Node SDK. If they have one, you can
|
||||
install it and then use their API documentation to get started and create tasks. If they
|
||||
don't, there are often third party SDKs you can use instead.
|
||||
</Paragraph>
|
||||
<Paragraph spacing className="mb-4">
|
||||
You can use one of our examples below as a starting point / reference for your projects.
|
||||
Please{" "}
|
||||
<Feedback
|
||||
button={
|
||||
@@ -99,24 +76,9 @@ export function CustomHelp({ api }: { api: Api }) {
|
||||
}
|
||||
defaultValue="help"
|
||||
/>{" "}
|
||||
if you're having any issues.
|
||||
if you're having any issues connecting to {api.name}, we'll help you get set up as
|
||||
quickly as possible.
|
||||
</Paragraph>
|
||||
|
||||
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700 sm:h-full">
|
||||
{fallbackExamples.map((example, index) => (
|
||||
<button
|
||||
onClick={() => changeCodeExample(index)}
|
||||
key={example.codeUrl}
|
||||
className={cn(
|
||||
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
|
||||
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
|
||||
)}
|
||||
>
|
||||
{example.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CodeExample example={fallbackExamples[selectedExample]} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function UpdateOAuthForm({
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
disabled={requiresCustomOAuthApp}
|
||||
readOnly={requiresCustomOAuthApp}
|
||||
onChange={(checked) => setUseMyOAuthApp(checked)}
|
||||
{...conform.input(hasCustomClient, { type: "checkbox" })}
|
||||
defaultChecked={requiresCustomOAuthApp}
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { loader } from "~/routes/resources.jobs.$jobId";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { JobStatusTable } from "../JobsStatusTable";
|
||||
import { JobEnvironment, JobStatusTable } from "../JobsStatusTable";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
|
||||
type JobEnvironment = {
|
||||
type: RuntimeEnvironmentType;
|
||||
lastRun?: Date;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
export function DeleteJobDialog({ id, title, slug }: { id: string; title: string; slug: string }) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
useEffect(() => {
|
||||
fetcher.load(`/resources/jobs/${id}`);
|
||||
}, [id]);
|
||||
|
||||
const isLoading = fetcher.state === "loading" || fetcher.state === "submitting";
|
||||
|
||||
if (isLoading || !fetcher.data) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
|
||||
<Header1>{title}</Header1>
|
||||
<Paragraph variant="small">ID: {slug}</Paragraph>
|
||||
</div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DeleteJobDialogContent
|
||||
id={id}
|
||||
title={title}
|
||||
slug={slug}
|
||||
environments={fetcher.data.environments}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteJobDialogContentProps = {
|
||||
id: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "../primitives/Table";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { runStatusTitle } from "../runs/RunStatuses";
|
||||
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
|
||||
import { DeleteJobDialog, DeleteJobDialogContent } from "./DeleteJobModalContent";
|
||||
import { JobStatusBadge } from "./JobStatusBadge";
|
||||
|
||||
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
|
||||
@@ -99,7 +99,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
{job.properties && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="flex max-w-[200px] items-start justify-start gap-5 truncate">
|
||||
<div className="flex max-w-[300px] items-start justify-start gap-5 truncate">
|
||||
{job.properties.map((property, index) => (
|
||||
<LabelValueStack
|
||||
key={index}
|
||||
@@ -165,12 +165,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Delete Job</DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
<DeleteJobDialog id={job.id} title={job.title} slug={job.slug} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCellMenu>
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import gradientPath from "./app-container-gradient.svg";
|
||||
|
||||
/** This container is used to surround the entire app, it correctly places the nav bar */
|
||||
export function AppContainer({
|
||||
children,
|
||||
showBackgroundGradient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
showBackgroundGradient?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BackgroundGradient showBackgroundGradient={showBackgroundGradient}>
|
||||
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>
|
||||
</BackgroundGradient>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundGradient({
|
||||
children,
|
||||
showBackgroundGradient,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
showBackgroundGradient?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("h-full w-full bg-contain bg-right-top bg-no-repeat")}
|
||||
style={
|
||||
showBackgroundGradient
|
||||
? {
|
||||
backgroundImage: `url(${gradientPath})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function AppContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
@@ -78,10 +44,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { ArrowLeftIcon, ChevronLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { useV3Enabled } from "~/root";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
const v3Enabled = useV3Enabled();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn("flex items-center justify-between border-b bg-background p-px transition")}
|
||||
>
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Account
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
|
||||
|
||||
<SideMenuItem
|
||||
name="Your profile"
|
||||
icon="account"
|
||||
iconColor="text-indigo-500"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
</div>
|
||||
{v3Enabled && (
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title="Security" />
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
iconColor="text-emerald-500"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function PageNavigationIndicator() {
|
||||
export function PageNavigationIndicator({ className }: { className?: string }) {
|
||||
const navigation = useNavigation();
|
||||
if (navigation.state === "loading") {
|
||||
return <Spinner color="muted" className="h-4 w-4" />;
|
||||
return <Spinner color="blue" className={cn("h-4 w-4", className)} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChartBarIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
CursorArrowRaysIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
import { AnchorHTMLAttributes, Fragment, useEffect, useRef, useState } from "react";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { MatchedProject } from "~/hooks/useProject";
|
||||
import { User } from "~/models/user.server";
|
||||
import { useV3Enabled } from "~/root";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
@@ -22,20 +26,28 @@ import {
|
||||
organizationBillingPath,
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
projectEventsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectSettingsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { UserAvatar, UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { type IconNames } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
@@ -45,8 +57,9 @@ import {
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<
|
||||
@@ -65,6 +78,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -93,11 +107,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<ProjectSelector organizations={organizations} project={project} />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
@@ -105,7 +115,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<SideMenuHeader title={"Project"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
@@ -120,6 +130,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"
|
||||
@@ -128,10 +144,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
data-action="triggers"
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Events"
|
||||
icon={CursorArrowRaysIcon}
|
||||
iconColor="text-sky-500"
|
||||
to={projectEventsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
iconColor="text-green-500"
|
||||
iconColor="text-pink-500"
|
||||
count={project.httpEndpointCount}
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
@@ -143,9 +165,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={organization.title}>
|
||||
<SideMenuHeader title={"Organization"}>
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
@@ -178,12 +207,83 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
name={isManagedCloud ? "Usage & Billing" : "Usage"}
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-pink-500"
|
||||
iconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="organization-settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
{currentPlan?.subscription?.isPaying === true && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={SlackIcon}
|
||||
data-action="join our slack"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Join our Slack
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Join our Slack</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
|
||||
<Paragraph variant="base/bright">
|
||||
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
|
||||
support with the Trigger.dev team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<hr className="border-slate-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Create a new Slack channel" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
In your Slack app, create a new channel from the main menu by going to File{" "}
|
||||
<ArrowRightIcon className="inline h-4 w-4 text-dimmed" /> New Channel
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Setup your channel" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Name your channel, set its visibility and click 'Create'.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Invite this email address to your channel:{" "}
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
value="james@trigger.dev"
|
||||
className="my-2"
|
||||
/>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
As soon as we can, we'll accept your invitation and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
@@ -212,6 +312,12 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{currentPlan && !currentPlan.subscription?.isPaying && currentPlan.usage.runCountCap && (
|
||||
<FreePlanUsage
|
||||
to={organizationBillingPath(organization)}
|
||||
percentage={currentPlan.usage.currentRunCount / currentPlan.usage.runCountCap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,11 +326,9 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organization,
|
||||
organizations,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
}) {
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
@@ -242,7 +346,7 @@ function ProjectSelector({
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{organization.title ?? "Select an organization"}</span>
|
||||
<span className="truncate">{project.name ?? "Select a project"}</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
@@ -253,23 +357,31 @@ function ProjectSelector({
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{organization.projects.length > 0 ? (
|
||||
organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New project"
|
||||
icon="plus"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
@@ -284,6 +396,7 @@ function ProjectSelector({
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const v3Enabled = useV3Enabled();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
@@ -321,6 +434,14 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
{v3Enabled && (
|
||||
<PopoverMenuItem
|
||||
to={personalAccessTokensPath()}
|
||||
title="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
leadingIconClassName="text-emerald-500"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Log out"
|
||||
@@ -333,99 +454,3 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuHeader({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between overflow-hidden">
|
||||
<span className="truncate">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-slate-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-slate-500 transition group-hover:text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AnchorHTMLAttributes } from "react";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { IconNames } from "../primitives/NamedIcon";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
count,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-bright group-hover:bg-slate-850",
|
||||
subItem ? "text-dimmed" : "",
|
||||
isActive ? "bg-slate-850 text-bright" : "group-hover:text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">
|
||||
{count !== undefined && count > 0 && <MenuCount count={count} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-rose-500 bg-rose-500/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-rose-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
}
|
||||
@@ -82,16 +82,16 @@ const variant = {
|
||||
"primary/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
|
||||
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:group-hover:bg-indigo-600",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"secondary/large": {
|
||||
textColor: "text-dimmed px-1",
|
||||
textColor: "text-bright group-disabled:text-dimmed px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
|
||||
"h-10 px-2 text-sm transition font-medium bg-slate-800 group-hover:bg-slate-700/70 group-disabled:opacity-40 group-disabled:group-hover:bg-slate-800",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BookOpenIcon,
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
@@ -58,6 +59,12 @@ export const variantClasses = {
|
||||
textColor: "text-blue-300",
|
||||
linkClassName: "transition hover:bg-blue-400/40",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-indigo-400/20 bg-indigo-800/30",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-indigo-500" />,
|
||||
textColor: "text-indigo-200",
|
||||
linkClassName: "transition hover:bg-indigo-400/40",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type CalloutVariant = keyof typeof variantClasses;
|
||||
@@ -66,12 +73,14 @@ export function Callout({
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
cta,
|
||||
variant,
|
||||
to,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
cta?: React.ReactNode;
|
||||
variant: CalloutVariant;
|
||||
to?: string;
|
||||
}) {
|
||||
@@ -84,13 +93,13 @@ export function Callout({
|
||||
href={to}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
`flex w-full items-start justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
|
||||
`flex w-full items-center justify-between gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
|
||||
variantDefinition.className,
|
||||
variantDefinition.linkClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={"flex w-full items-start gap-x-2"}>
|
||||
<div className={"flex w-full items-center gap-x-2"}>
|
||||
{icon ? icon : variantDefinition.icon}
|
||||
|
||||
{typeof children === "string" ? (
|
||||
@@ -135,20 +144,24 @@ export function Callout({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
`flex w-full items-start gap-2.5 rounded-md border py-2 pl-2 pr-3 shadow-md backdrop-blur-sm`,
|
||||
"flex w-full items-center gap-2 rounded-md border pl-2 pr-2 shadow-md backdrop-blur-sm",
|
||||
cta ? "py-2" : "py-2.5",
|
||||
variantDefinition.className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon ? icon : variantDefinition.icon}
|
||||
<div className={cn(`flex w-full items-center gap-2.5`)}>
|
||||
{icon ? icon : variantDefinition.icon}
|
||||
|
||||
{typeof children === "string" ? (
|
||||
<Paragraph variant={"small"} className={variantDefinition.textColor}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{typeof children === "string" ? (
|
||||
<Paragraph variant={"small"} className={variantDefinition.textColor}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
{cta && cta}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,14 +109,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-start gap-x-2 transition",
|
||||
"group flex items-start gap-x-2 transition ",
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
buttonClassName,
|
||||
isChecked && isCheckedClassName,
|
||||
isDisabled && isDisabledClassName,
|
||||
(isDisabled || props.readOnly) && isDisabledClassName,
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) return;
|
||||
//returning false is not setting the state to false, it stops the event from bubbling up
|
||||
if (isDisabled || props.readOnly === true) return false;
|
||||
setIsChecked((c) => !c);
|
||||
}}
|
||||
>
|
||||
@@ -127,12 +129,15 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
value={value}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
//returning false is not setting the state to false, it stops the event from bubbling up
|
||||
if (isDisabled || props.readOnly === true) return false;
|
||||
setIsChecked(!isChecked);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
inputPositionClasses,
|
||||
"cursor-pointer rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
"rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 read-only:border-slate-650 read-only:!bg-slate-700 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
|
||||
)}
|
||||
id={id}
|
||||
ref={ref}
|
||||
@@ -141,7 +146,10 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
<div className="flex items-center gap-x-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn("cursor-pointer", labelClassName)}
|
||||
className={cn(
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
labelClassName
|
||||
)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const ClientTabs = TabsPrimitive.Root;
|
||||
|
||||
@@ -48,4 +49,47 @@ const ClientTabsContent = React.forwardRef<
|
||||
));
|
||||
ClientTabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
};
|
||||
|
||||
export function ClientTabsWithUnderline({ className, tabs, currentValue, layoutId }: TabsProps) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(`flex flex-row gap-x-6 border-b border-slate-700`, className)}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = currentValue === tab.value;
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(`group flex flex-col items-center`, className)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-indigo-500" : "text-slate-200"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<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" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
import { useDateFieldState } from "@react-stately/datepicker";
|
||||
import { Granularity } from "@react-types/datepicker";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
type DateFieldProps = {
|
||||
label?: string;
|
||||
defaultValue?: Date;
|
||||
minValue?: Date;
|
||||
maxValue?: Date;
|
||||
className?: string;
|
||||
fieldClassName?: string;
|
||||
granularity: Granularity;
|
||||
showGuide?: boolean;
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
onValueChange?: (value: Date | undefined) => void;
|
||||
};
|
||||
|
||||
export function DateField({
|
||||
label,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
minValue,
|
||||
maxValue,
|
||||
granularity,
|
||||
className,
|
||||
fieldClassName,
|
||||
showGuide = false,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
}: DateFieldProps) {
|
||||
const [value, setValue] = useState<undefined | CalendarDateTime>(
|
||||
utcDateToCalendarDate(defaultValue)
|
||||
);
|
||||
|
||||
const state = useDateFieldState({
|
||||
value: value,
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
setValue(value);
|
||||
onValueChange?.(value.toDate("utc"));
|
||||
}
|
||||
},
|
||||
minValue: utcDateToCalendarDate(minValue),
|
||||
maxValue: utcDateToCalendarDate(maxValue),
|
||||
shouldForceLeadingZeros: true,
|
||||
granularity,
|
||||
locale: "en-US",
|
||||
createCalendar: (name: string) => {
|
||||
return createCalendar(name);
|
||||
},
|
||||
});
|
||||
|
||||
//if the passed in value changes, we should update the date
|
||||
useEffect(() => {
|
||||
if (state.value === undefined && defaultValue === undefined) return;
|
||||
|
||||
const calendarDate = utcDateToCalendarDate(defaultValue);
|
||||
//unchanged
|
||||
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(calendarDate);
|
||||
}, [defaultValue]);
|
||||
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { labelProps, fieldProps } = useDateField(
|
||||
{
|
||||
label,
|
||||
},
|
||||
state,
|
||||
ref
|
||||
);
|
||||
|
||||
//render if reverse date order
|
||||
const yearSegment = state.segments.find((s) => s.type === "year")!;
|
||||
const monthSegment = state.segments.find((s) => s.type === "month")!;
|
||||
const daySegment = state.segments.find((s) => s.type === "day")!;
|
||||
const hourSegment = state.segments.find((s) => s.type === "hour")!;
|
||||
const minuteSegment = state.segments.find((s) => s.type === "minute")!;
|
||||
const secondSegment = state.segments.find((s) => s.type === "second")!;
|
||||
const dayPeriodSegment = state.segments.find((s) => s.type === "dayPeriod")!;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-start ${className || ""}`}>
|
||||
<span {...labelProps} className="mb-1 ml-0.5 text-xs text-slate-300">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div
|
||||
{...fieldProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex rounded-sm border border-slate-800 bg-midnight-900 p-0.5 px-1.5 transition-colors focus-within:border-slate-500 hover:border-slate-700 focus-within:hover:border-slate-500",
|
||||
fieldClassName
|
||||
)}
|
||||
>
|
||||
<DateSegment segment={yearSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={monthSegment} state={state} />
|
||||
<DateSegment segment={literalSegment("/")} state={state} />
|
||||
<DateSegment segment={daySegment} state={state} />
|
||||
<DateSegment segment={literalSegment(", ")} state={state} />
|
||||
<DateSegment segment={hourSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={minuteSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(":")} state={state} />
|
||||
<DateSegment segment={secondSegment} state={state} />
|
||||
<DateSegment segment={literalSegment(" ")} state={state} />
|
||||
<DateSegment segment={dayPeriodSegment} state={state} />
|
||||
</div>
|
||||
{showNowButton && (
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setValue(utcDateToCalendarDate(new Date()));
|
||||
onValueChange?.(now);
|
||||
}}
|
||||
>
|
||||
Now
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={"close"}
|
||||
onClick={() => {
|
||||
setValue(undefined);
|
||||
onValueChange?.(undefined);
|
||||
state.clearSegment("year");
|
||||
state.clearSegment("month");
|
||||
state.clearSegment("day");
|
||||
state.clearSegment("hour");
|
||||
state.clearSegment("minute");
|
||||
state.clearSegment("second");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showGuide && (
|
||||
<div className="mt-1 flex px-2">
|
||||
{state.segments.map((segment, i) => (
|
||||
<DateSegmentGuide key={i} segment={segment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function utcDateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds()
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type DateSegmentProps = {
|
||||
segment: DateSegment;
|
||||
state: DateFieldState;
|
||||
};
|
||||
|
||||
function DateSegment({ segment, state }: DateSegmentProps) {
|
||||
const ref = useRef<null | HTMLDivElement>(null);
|
||||
const { segmentProps } = useDateSegment(segment, state, ref);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...segmentProps}
|
||||
ref={ref}
|
||||
style={{
|
||||
...segmentProps.style,
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none focus:bg-indigo-500 focus:text-white ${
|
||||
!segment.isEditable ? "text-slate-500" : "text-bright"
|
||||
}`}
|
||||
>
|
||||
{/* Always reserve space for the placeholder, to prevent layout shift when editing. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block text-center italic text-slate-500 group-focus:text-white"
|
||||
style={{
|
||||
visibility: segment.isPlaceholder ? undefined : "hidden",
|
||||
height: segment.isPlaceholder ? "" : 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{segment.placeholder}
|
||||
</span>
|
||||
{segment.isPlaceholder ? "" : segment.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function literalSegment(text: string): DateSegment {
|
||||
return {
|
||||
type: "literal",
|
||||
text,
|
||||
isPlaceholder: false,
|
||||
isEditable: false,
|
||||
placeholder: "",
|
||||
};
|
||||
}
|
||||
|
||||
function minWidthForSegment(segment: DateSegment) {
|
||||
if (segment.type === "literal") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return String(`${segment.maxValue}`).length + "ch";
|
||||
}
|
||||
|
||||
function DateSegmentGuide({ segment }: { segment: DateSegment }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minWidth: minWidthForSegment(segment),
|
||||
}}
|
||||
className={`group box-content rounded-sm px-0.5 text-right text-sm tabular-nums outline-none ${
|
||||
!segment.isEditable ? "text-slate-500" : "text-bright"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-center italic text-slate-500">
|
||||
{segment.type !== "literal" ? segment.placeholder : segment.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,26 @@ type DateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
includeSeconds?: boolean;
|
||||
includeTime?: boolean;
|
||||
};
|
||||
|
||||
export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: DateTimeProps) => {
|
||||
export const DateTime = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
includeSeconds = true,
|
||||
includeTime = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
const initialFormattedDateTime = formatDateTime(realDate, timeZone, locales, includeSeconds);
|
||||
const initialFormattedDateTime = formatDateTime(
|
||||
realDate,
|
||||
timeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime
|
||||
);
|
||||
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
@@ -20,26 +32,27 @@ export const DateTime = ({ date, timeZone = "UTC", includeSeconds = true }: Date
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
|
||||
setFormattedDateTime(
|
||||
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds)
|
||||
formatDateTime(realDate, resolvedOptions.timeZone, locales, includeSeconds, includeTime)
|
||||
);
|
||||
}, [locales, includeSeconds, realDate]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
function formatDateTime(
|
||||
export function formatDateTime(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
includeSeconds: boolean
|
||||
includeSeconds: boolean,
|
||||
includeTime: boolean
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: includeSeconds ? "numeric" : undefined,
|
||||
hour: includeTime ? "numeric" : undefined,
|
||||
minute: includeTime ? "numeric" : undefined,
|
||||
second: includeTime && includeSeconds ? "numeric" : undefined,
|
||||
timeZone,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
|
||||
"fixed inset-0 z-50 bg-background/90 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-midnight-800 px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background px-4 pb-6 pt-3 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type FormSegmentedControlProps = {
|
||||
name: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function FormSegmentedControl({
|
||||
name,
|
||||
defaultValue,
|
||||
options,
|
||||
onChange,
|
||||
}: FormSegmentedControlProps) {
|
||||
return (
|
||||
<div className="flex h-8 w-fit rounded bg-slate-800 p-1">
|
||||
<RadioGroup
|
||||
defaultValue={defaultValue ?? options[0].value}
|
||||
name={name}
|
||||
onChange={(c: string) => {
|
||||
if (onChange) {
|
||||
onChange(c);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-x-1.5">
|
||||
{options.map((option) => (
|
||||
<RadioGroup.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, checked }) =>
|
||||
cn(
|
||||
"relative flex cursor-pointer rounded-[2px] px-3 py-[0.13rem] shadow-md focus:outline-none",
|
||||
active
|
||||
? "focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
checked
|
||||
? "bg-slate-700 text-bright"
|
||||
: "bg-transparent transition hover:bg-slate-750"
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ checked }) => (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm">
|
||||
<RadioGroup.Label
|
||||
as="p"
|
||||
className={cn("font-normal", checked ? "text-bright" : "text-dimmed")}
|
||||
>
|
||||
{option.label}
|
||||
</RadioGroup.Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -2,11 +2,12 @@ import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { IconNamesOrString } from "./NamedIcon";
|
||||
import { NamedIcon } from "./NamedIcon";
|
||||
import { Icon, RenderIcon } from "./Icon";
|
||||
|
||||
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 +34,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]",
|
||||
@@ -44,7 +45,7 @@ const variants = {
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
variant?: keyof typeof variants;
|
||||
icon?: IconNamesOrString;
|
||||
icon?: RenderIcon;
|
||||
shortcut?: string;
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
@@ -59,7 +60,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
|
||||
{icon && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
|
||||
<NamedIcon name={icon} className={cn(iconClassName, "text-dimmed")} />
|
||||
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -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 !== undefined && ":"}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Paragraph variant="small">{value}</Paragraph>
|
||||
{value !== undefined && <Paragraph variant="small">{value}</Paragraph>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={true}
|
||||
className={cn(
|
||||
"z-50 min-w-max rounded-md border bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-max rounded-md border border-slate-700 bg-midnight-850 p-4 text-popover-foreground shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SegmentedControlProps = {
|
||||
name: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
fullWidth?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function SegmentedControl({
|
||||
name,
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
fullWidth,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<div className={cn("flex h-10 rounded bg-slate-850", fullWidth ? "w-full" : "w-fit")}>
|
||||
<RadioGroup
|
||||
value={value}
|
||||
defaultValue={defaultValue ?? options[0].value}
|
||||
name={name}
|
||||
onChange={(c: string) => {
|
||||
if (onChange) {
|
||||
onChange(c);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-between gap-x-1 p-1">
|
||||
{options.map((option) => (
|
||||
<RadioGroup.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, checked }) =>
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
|
||||
active
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-indigo-500 focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
checked
|
||||
? "text-bright"
|
||||
: "rounded-[2px] text-dimmed transition hover:bg-slate-750/50 hover:text-bright"
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ checked }) => (
|
||||
<>
|
||||
<div className="relative flex h-full w-full items-center justify-between px-3 py-[0.13rem]">
|
||||
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
|
||||
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
|
||||
</div>
|
||||
{checked && (
|
||||
<motion.div
|
||||
layoutId={`segmented-control-${name}`}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] bg-indigo-600 shadow-md"
|
||||
></motion.div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md bg-popover text-bright shadow-md animate-in fade-in-40",
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-slate-700 bg-popover text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"border border-slate-800 px-1 py-0",
|
||||
"px-1 py-0",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
|
||||
@@ -122,10 +122,26 @@ type TableCellProps = TableCellBasicProps & {
|
||||
to?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
hasAction?: boolean;
|
||||
isSticky?: boolean;
|
||||
};
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, to, onClick, hasAction = false }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
alignment = "left",
|
||||
children,
|
||||
colSpan,
|
||||
to,
|
||||
onClick,
|
||||
hasAction = false,
|
||||
isSticky = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
case "center":
|
||||
@@ -154,6 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
? "cursor-pointer group-hover:bg-slate-900"
|
||||
: "px-4 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
@@ -174,9 +191,6 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
);
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem] before:bg-gradient-to-r before:from-transparent before:to-background before:content-[''] group-hover:before:to-slate-900";
|
||||
|
||||
export const TableCellChevron = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
@@ -189,7 +203,8 @@ export const TableCellChevron = forwardRef<
|
||||
>(({ className, to, children, isSticky, onClick }, ref) => {
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
@@ -213,7 +228,8 @@ export const TableCellMenu = forwardRef<
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<TableCell
|
||||
className={cn(isSticky && stickyStyles, className)}
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Toaster, toast } from "sonner";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -38,26 +39,23 @@ export function ToastUI({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`self-end rounded-lg border border-slate-750 bg-midnight-900 shadow-md`}
|
||||
className={`self-end rounded-md border border-border bg-background`}
|
||||
style={{
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex w-full gap-2 rounded-lg bg-no-repeat p-4 text-bright"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(at top, hsla(271, 91%, 65%, 0.18), hsla(221, 83%, 53%, 0.18)) hsla(221, 83%, 53%, 0.18)",
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-600" />
|
||||
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="h-6 w-6 text-rose-600" />
|
||||
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
|
||||
)}
|
||||
{message}
|
||||
<button className="ms-auto p-1" onClick={() => toast.dismiss(t)}>
|
||||
<XMarkIcon className="h-4 w-4 text-bright" />
|
||||
<Paragraph className="py-1 text-dimmed">{message}</Paragraph>
|
||||
<button
|
||||
className="ms-auto rounded p-2 text-dimmed transition hover:bg-midnight-800 hover:text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-popover border border-slate-800 rounded-md px-3 py-1.5 text-sm text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background border border-border rounded px-3 py-2 text-sm text-bright shadow-md fade-in-50",
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const TooltipArrow = React.forwardRef<
|
||||
@@ -22,15 +28,20 @@ Tooltip.displayName = TooltipPrimitive.Root.displayName;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
type TooltipContentProps = {
|
||||
variant?: Variant;
|
||||
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
TooltipContentProps
|
||||
>(({ className, sideOffset = 4, variant = "basic", ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-slate-800 bg-popover px-3 py-1.5 text-sm text-bright shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -43,17 +54,56 @@ function SimpleTooltip({
|
||||
content,
|
||||
side,
|
||||
hidden,
|
||||
variant,
|
||||
className,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
side?: React.ComponentProps<typeof TooltipContent>["side"];
|
||||
hidden?: boolean;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{button}</TooltipTrigger>
|
||||
<TooltipContent side={side} hidden={hidden} className="text-xs">
|
||||
<TooltipTrigger className="h-fit">{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
hidden={hidden}
|
||||
className={cn("text-xs", className)}
|
||||
variant={variant}
|
||||
>
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
} from "@remix-run/react";
|
||||
import { JobRunStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { RuntimeEnvironmentType, User } 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";
|
||||
@@ -26,6 +27,7 @@ import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import {
|
||||
PageButtons,
|
||||
@@ -38,14 +40,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,
|
||||
@@ -71,11 +66,12 @@ type RunOverviewProps = {
|
||||
run: string;
|
||||
runsPath: string;
|
||||
};
|
||||
currentUser: User;
|
||||
};
|
||||
|
||||
const taskPattern = /\/tasks\/(.*)/;
|
||||
|
||||
export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps) {
|
||||
export function RunOverview({ run, trigger, showRerun, paths, currentUser }: RunOverviewProps) {
|
||||
const navigate = useNavigate();
|
||||
const pathName = usePathName();
|
||||
|
||||
@@ -95,7 +91,8 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
}
|
||||
}, [pathName]);
|
||||
|
||||
const basicStatus = runBasicStatus(run.status);
|
||||
const usernameForEnv =
|
||||
currentUser.id !== run.environment.userId ? run.environment.userName : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -106,7 +103,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 +114,16 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
Test run
|
||||
</span>
|
||||
)}
|
||||
{showRerun && hasFinished(run.status) && (
|
||||
{showRerun && run.isFinished && (
|
||||
<RerunPopover
|
||||
runId={run.id}
|
||||
runPath={paths.run}
|
||||
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>
|
||||
@@ -141,16 +141,26 @@ export function RunOverview({ run, trigger, showRerun, paths }: RunOverviewProps
|
||||
<PageInfoProperty icon={"property"} label={"Version"} value={`v${run.version}`} />
|
||||
<PageInfoProperty
|
||||
label={"Env"}
|
||||
value={<EnvironmentLabel environment={run.environment} />}
|
||||
value={<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon={"clock"}
|
||||
label={"Duration"}
|
||||
value={formatDuration(run.startedAt, run.completedAt)}
|
||||
value={formatDuration(run.startedAt, run.completedAt, { style: "short" })}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon={<Icon icon="alarm-filled" className="h-4 w-4 text-blue-500" />}
|
||||
label={"Execution Time"}
|
||||
value={formatDurationMilliseconds(run.executionDuration, { style: "short" })}
|
||||
/>
|
||||
<PageInfoProperty
|
||||
icon={<Icon icon="list-numbers" className="h-4 w-4 text-yellow-500" />}
|
||||
label={"Execution Count"}
|
||||
value={<>{run.executionCount}</>}
|
||||
/>
|
||||
</PageInfoGroup>
|
||||
<PageInfoGroup alignment="right">
|
||||
<Paragraph variant="extra-small" className="text-slate-600">
|
||||
<Paragraph variant="extra-small" className="whitespace-nowrap text-slate-600">
|
||||
RUN ID: {run.id}
|
||||
</Paragraph>
|
||||
</PageInfoGroup>
|
||||
@@ -211,10 +221,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 +295,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>;
|
||||
@@ -314,18 +318,20 @@ function BlankTasks({
|
||||
|
||||
function RerunPopover({
|
||||
runId,
|
||||
runPath,
|
||||
runsPath,
|
||||
environmentType,
|
||||
status,
|
||||
}: {
|
||||
runId: string;
|
||||
runPath: string;
|
||||
runsPath: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
status: RunBasicStatus;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { successRedirect }] = useForm({
|
||||
const [form, { successRedirect, failureRedirect }] = useForm({
|
||||
id: "rerun",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
@@ -344,6 +350,7 @@ function RerunPopover({
|
||||
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
|
||||
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
|
||||
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
|
||||
<input {...conform.input(failureRedirect, { type: "hidden" })} defaultValue={runPath} />
|
||||
{environmentType === "PRODUCTION" && (
|
||||
<div className="px-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
FilterableEnvironment,
|
||||
FilterableStatus,
|
||||
RunListSearchSchema,
|
||||
environmentKeys,
|
||||
statusKeys,
|
||||
} from "./RunStatuses";
|
||||
import { TimeFrameFilter } from "./TimeFrameFilter";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function RunsFilters() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environment, status, from, to } = RunListSearchSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback((value: FilterableStatus | "ALL") => {
|
||||
handleFilterChange("status", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = useCallback((value: FilterableEnvironment | "ALL") => {
|
||||
handleFilterChange("environment", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
|
||||
if (range.from) {
|
||||
searchParams.set("from", range.from.toString());
|
||||
} else {
|
||||
searchParams.delete("from");
|
||||
}
|
||||
|
||||
if (range.to) {
|
||||
searchParams.set("to", range.to.toString());
|
||||
} else {
|
||||
searchParams.delete("to");
|
||||
}
|
||||
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("status");
|
||||
searchParams.delete("environment");
|
||||
searchParams.delete("from");
|
||||
searchParams.delete("to");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between gap-x-2">
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environment ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{environmentKeys.map((env) => (
|
||||
<SelectItem key={env} value={env}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={{ type: env }} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select name="status" value={status ?? "ALL"} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger size="secondary/small" width="full">
|
||||
<SelectValue placeholder="Select status" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph variant="extra-small" className="pl-0.5">
|
||||
All statuses
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{statusKeys.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<FilterStatusIcon status={status} className="h-4 w-4" />
|
||||
<FilterStatusLabel status={status} />
|
||||
</span>
|
||||
}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="tertiary/small" onClick={() => clearFilters()} LeadingIcon={"close"}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterStatusLabel({ status }: { status: FilterableStatus }) {
|
||||
return <span className={filterStatusClassNameColor(status)}>{filterStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function FilterStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: FilterableStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "WAITING":
|
||||
return <ClockIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "QUEUED":
|
||||
return <PauseCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "IN_PROGRESS":
|
||||
return <Spinner className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "TIMEDOUT":
|
||||
return (
|
||||
<ExclamationTriangleIcon className={cn(filterStatusClassNameColor(status), className)} />
|
||||
);
|
||||
case "CANCELED":
|
||||
return <NoSymbolIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(filterStatusClassNameColor(status), className)} />;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusTitle(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
return "Queued";
|
||||
case "IN_PROGRESS":
|
||||
return "In progress";
|
||||
case "WAITING":
|
||||
return "Waiting";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "CANCELED":
|
||||
return "Canceled";
|
||||
case "TIMEDOUT":
|
||||
return "Timed out";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterStatusClassNameColor(status: FilterableStatus): string {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
return "text-slate-500";
|
||||
case "IN_PROGRESS":
|
||||
return "text-blue-500";
|
||||
case "WAITING":
|
||||
return "text-blue-500";
|
||||
case "COMPLETED":
|
||||
return "text-green-500";
|
||||
case "FAILED":
|
||||
return "text-rose-500";
|
||||
case "CANCELED":
|
||||
return "text-slate-500";
|
||||
case "TIMEDOUT":
|
||||
return "text-amber-300";
|
||||
default: {
|
||||
const _exhaustiveCheck: never = status;
|
||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,14 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PauseCircleIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
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"
|
||||
);
|
||||
}
|
||||
import { z } from "zod";
|
||||
|
||||
export function RunStatus({ status }: { status: JobRunStatus }) {
|
||||
return (
|
||||
@@ -40,49 +30,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 +66,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 +102,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 +122,58 @@ 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
|
||||
export type Direction = z.infer<typeof DirectionSchema>;
|
||||
|
||||
export const FilterableStatus = z.union([
|
||||
z.literal("QUEUED"),
|
||||
z.literal("IN_PROGRESS"),
|
||||
z.literal("WAITING"),
|
||||
z.literal("COMPLETED"),
|
||||
z.literal("FAILED"),
|
||||
z.literal("TIMEDOUT"),
|
||||
z.literal("CANCELED"),
|
||||
]);
|
||||
export type FilterableStatus = z.infer<typeof FilterableStatus>;
|
||||
|
||||
export const FilterableEnvironment = z.union([
|
||||
z.literal("DEVELOPMENT"),
|
||||
z.literal("STAGING"),
|
||||
z.literal("PRODUCTION"),
|
||||
]);
|
||||
export type FilterableEnvironment = z.infer<typeof FilterableEnvironment>;
|
||||
export const environmentKeys: FilterableEnvironment[] = ["DEVELOPMENT", "STAGING", "PRODUCTION"];
|
||||
|
||||
export const RunListSearchSchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: DirectionSchema.optional(),
|
||||
status: FilterableStatus.optional(),
|
||||
environment: FilterableEnvironment.optional(),
|
||||
from: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.transform((value) => parseInt(value))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const filterableStatuses: Record<FilterableStatus, JobRunStatus[]> = {
|
||||
QUEUED: ["QUEUED", "WAITING_TO_EXECUTE", "PENDING", "WAITING_ON_CONNECTIONS"],
|
||||
IN_PROGRESS: ["STARTED", "EXECUTING", "PREPROCESSING"],
|
||||
WAITING: ["WAITING_TO_CONTINUE"],
|
||||
COMPLETED: ["SUCCESS"],
|
||||
FAILED: ["FAILURE", "UNRESOLVED_AUTH", "INVALID_PAYLOAD", "ABORTED"],
|
||||
TIMEDOUT: ["TIMED_OUT"],
|
||||
CANCELED: ["CANCELED"],
|
||||
};
|
||||
|
||||
export const statusKeys: FilterableStatus[] = Object.keys(filterableStatuses) as FilterableStatus[];
|
||||
|
||||
@@ -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 { JobRunStatus, RuntimeEnvironmentType, User } from "@trigger.dev/database";
|
||||
import { formatDuration, formatDurationMilliseconds } from "~/utils";
|
||||
import { EnvironmentLabel } from "../environments/EnvironmentLabel";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
@@ -20,14 +20,18 @@ import { RunStatus } from "./RunStatuses";
|
||||
|
||||
type RunTableItem = {
|
||||
id: string;
|
||||
number: number;
|
||||
number: number | null;
|
||||
environment: {
|
||||
type: RuntimeEnvironmentType;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
};
|
||||
job: { title: string; slug: string };
|
||||
status: JobRunStatus;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date | null;
|
||||
executionDuration: number;
|
||||
version: string;
|
||||
isTest: boolean;
|
||||
};
|
||||
@@ -35,9 +39,11 @@ type RunTableItem = {
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
hasFilters: boolean;
|
||||
showJob?: boolean;
|
||||
runs: RunTableItem[];
|
||||
isLoading?: boolean;
|
||||
runsParentPath: string;
|
||||
currentUser: User;
|
||||
};
|
||||
|
||||
export function RunsTable({
|
||||
@@ -45,17 +51,21 @@ export function RunsTable({
|
||||
hasFilters,
|
||||
runs,
|
||||
isLoading = false,
|
||||
showJob = false,
|
||||
runsParentPath,
|
||||
currentUser,
|
||||
}: RunsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<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,21 +76,28 @@ export function RunsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<NoRuns title="No Runs found for this Job" />
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
{!isLoading && <NoRuns title="No runs found" />}
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<NoRuns title="No Runs match your filters" />
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
{!isLoading && <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`;
|
||||
const usernameForEnv =
|
||||
currentUser.id !== run.environment.userId ? run.environment.userName : undefined;
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell to={path}>#{run.number}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel environment={run.environment} />
|
||||
{typeof run.number === "number" ? `#${run.number}` : "-"}
|
||||
</TableCell>
|
||||
{showJob && <TableCell to={path}>{run.job.slug}</TableCell>}
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel environment={run.environment} userName={usernameForEnv} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<RunStatus status={run.status} />
|
||||
@@ -93,6 +110,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 +143,7 @@ export function RunsTable({
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function NoRuns({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Calendar,
|
||||
CalendarDateTime,
|
||||
DateValue,
|
||||
getLocalTimeZone,
|
||||
today,
|
||||
} from "@internationalized/date";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ClientTabs, ClientTabsContent, ClientTabsWithUnderline } from "../primitives/ClientTabs";
|
||||
import { formatDateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { DateField } from "../primitives/DateField";
|
||||
import { useLocales } from "../primitives/LocaleProvider";
|
||||
import { createCalendar } from "@internationalized/date";
|
||||
|
||||
type RunTimeFrameFilterProps = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
onRangeChanged: (range: { from?: number; to?: number }) => void;
|
||||
};
|
||||
|
||||
type Mode = "absolute" | "relative";
|
||||
|
||||
export function TimeFrameFilter({ from, to, onRangeChanged }: RunTimeFrameFilterProps) {
|
||||
const [activeTab, setActiveTab] = useState<Mode>("absolute");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [relativeTimeSeconds, setRelativeTimeSeconds] = useState<number | undefined>();
|
||||
|
||||
const fromDate = from ? new Date(from) : undefined;
|
||||
const toDate = to ? new Date(to) : undefined;
|
||||
|
||||
const relativeTimeFrameChanged = useCallback((value: number) => {
|
||||
const to = new Date().getTime();
|
||||
const from = to - value;
|
||||
onRangeChanged({ from, to });
|
||||
setRelativeTimeSeconds(value);
|
||||
}, []);
|
||||
|
||||
const absoluteTimeFrameChanged = useCallback(({ from, to }: { from?: Date; to?: Date }) => {
|
||||
setRelativeTimeSeconds(undefined);
|
||||
const fromTime = from?.getTime();
|
||||
const toTime = to?.getTime();
|
||||
onRangeChanged({ from: fromTime, to: toTime });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
className="bg-slate-800 group-hover:bg-tertiary-foreground"
|
||||
>
|
||||
<Paragraph variant="extra-small" className="mr-2">
|
||||
{title(from, to, relativeTimeSeconds)}
|
||||
</Paragraph>
|
||||
<ChevronDownIcon className="h-4 w-4 text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="bg-popover p-2">
|
||||
<ClientTabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as Mode)}
|
||||
className="p-1"
|
||||
>
|
||||
<ClientTabsWithUnderline
|
||||
tabs={[
|
||||
{ label: "Absolute", value: "absolute" },
|
||||
{ label: "Relative", value: "relative" },
|
||||
]}
|
||||
currentValue={activeTab}
|
||||
layoutId={"time-tabs"}
|
||||
/>
|
||||
<ClientTabsContent value={"absolute"}>
|
||||
<AbsoluteTimeFrame
|
||||
from={fromDate}
|
||||
to={toDate}
|
||||
onValueChange={absoluteTimeFrameChanged}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"relative"}>
|
||||
<RelativeTimeFrame
|
||||
value={relativeTimeSeconds}
|
||||
onValueChange={relativeTimeFrameChanged}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function title(
|
||||
from: number | undefined,
|
||||
to: number | undefined,
|
||||
relativeTimeSeconds: number | undefined
|
||||
): string {
|
||||
if (!from && !to) {
|
||||
return "All time periods";
|
||||
}
|
||||
|
||||
if (relativeTimeSeconds !== undefined) {
|
||||
return timeFrameValues.find((t) => t.value === relativeTimeSeconds)?.label ?? "Timeframe";
|
||||
}
|
||||
|
||||
let fromString = from ? formatDateTime(new Date(from), "UTC", ["en-US"], false, true) : undefined;
|
||||
let toString = to ? formatDateTime(new Date(to), "UTC", ["en-US"], false, true) : undefined;
|
||||
if (from && !to) {
|
||||
return `From ${fromString} (UTC)`;
|
||||
}
|
||||
|
||||
if (!from && to) {
|
||||
return `To ${toString} (UTC)`;
|
||||
}
|
||||
|
||||
return `${fromString} - ${toString} (UTC)`;
|
||||
}
|
||||
|
||||
function RelativeTimeFrame({
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
value?: number;
|
||||
onValueChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1 pt-2">
|
||||
{timeFrameValues.map((timeframe) => (
|
||||
<Button
|
||||
key={timeframe.value}
|
||||
variant={value === timeframe.value ? "primary/small" : "tertiary/small"}
|
||||
className={cn(
|
||||
"w-full",
|
||||
value !== timeframe.value && "border border-slate-700 group-hover:bg-slate-700"
|
||||
)}
|
||||
onClick={() => {
|
||||
onValueChange(timeframe.value);
|
||||
}}
|
||||
>
|
||||
<Paragraph variant="extra-small">{timeframe.label}</Paragraph>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeFrameValues = [
|
||||
{
|
||||
label: "5 mins",
|
||||
value: 5 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "15 mins",
|
||||
value: 15 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "30 mins",
|
||||
value: 30 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "1 hour",
|
||||
value: 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "3 hours",
|
||||
value: 3 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "6 hours",
|
||||
value: 6 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "1 day",
|
||||
value: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "3 days",
|
||||
value: 3 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "7 days",
|
||||
value: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "10 days",
|
||||
value: 10 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "14 days",
|
||||
value: 14 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
label: "30 days",
|
||||
value: 30 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
];
|
||||
|
||||
export type RelativeTimeFrameItem = (typeof timeFrameValues)[number];
|
||||
|
||||
function AbsoluteTimeFrame({
|
||||
from,
|
||||
to,
|
||||
onValueChange,
|
||||
}: {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
onValueChange: (value: { from?: Date; to?: Date }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={from}
|
||||
onValueChange={(value) => {
|
||||
onValueChange({ from: value, to: to });
|
||||
}}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<DateField
|
||||
label="To (UTC)"
|
||||
defaultValue={to}
|
||||
onValueChange={(value) => {
|
||||
onValueChange({ from: from, to: value });
|
||||
}}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
|
||||
const meta: Meta<typeof FreePlanUsageBar> = {
|
||||
title: "Billing/FreePlanUsage",
|
||||
component: FreePlanUsageBar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FreePlanUsageBar>;
|
||||
|
||||
const mockOrganization: MatchedOrganization = {
|
||||
id: "mockID",
|
||||
title: "mockTitle",
|
||||
slug: "mockSlug",
|
||||
projects: [
|
||||
{ id: "mockId1", slug: "mockSlug1", name: "mockName1", jobCount: 1 },
|
||||
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
|
||||
],
|
||||
hasUnconfiguredIntegrations: false,
|
||||
runsEnabled: true,
|
||||
};
|
||||
|
||||
export const ProgressBar: Story = {
|
||||
args: {
|
||||
organization: mockOrganization,
|
||||
},
|
||||
render: (args) => <FreePlanUsageBar {...args} />,
|
||||
};
|
||||
|
||||
type FreePlanUsageBarProps = {
|
||||
organization: MatchedOrganization;
|
||||
};
|
||||
|
||||
function FreePlanUsageBar({ organization }: FreePlanUsageBarProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center p-12">
|
||||
<div className="w-fit">
|
||||
<FreePlanUsage to={organizationBillingPath(organization)} percentage={0.75} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
|
||||
const meta: Meta<typeof PricingCallouts> = {
|
||||
title: "Billing/PricingCallouts",
|
||||
component: PricingCallouts,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof PricingCallouts>;
|
||||
|
||||
export const Callouts: Story = {
|
||||
render: (args) => <PricingCallouts />,
|
||||
};
|
||||
|
||||
function PricingCallouts() {
|
||||
return (
|
||||
<div className="mx-4 flex h-screen flex-col items-center justify-center gap-4">
|
||||
<Callout
|
||||
variant={"pricing"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="pr-0 pl-0.5"
|
||||
to="#"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
Some of your runs are being queued because your run concurrency is limited to 50.
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-7
@@ -1,19 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { withDesign } from "storybook-addon-designs";
|
||||
import FormSegmentedControl from "../primitives/FormSegmentedControl";
|
||||
import { MainCenteredContainer } from "../layout/AppLayout";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Primitives/FormSegmentedControl",
|
||||
const meta: Meta<typeof StyledSegmentedControl> = {
|
||||
title: "Primitives/SegmentedControl",
|
||||
decorators: [withDesign],
|
||||
component: StyledSegmentedControl,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FormSegmentedControl>;
|
||||
type Story = StoryObj<typeof StyledSegmentedControl>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => <SegmentedControl />,
|
||||
render: () => <StyledSegmentedControl />,
|
||||
};
|
||||
|
||||
Basic.parameters = {
|
||||
@@ -28,10 +29,10 @@ const options = [
|
||||
{ label: "Label 2", value: "Users" },
|
||||
];
|
||||
|
||||
function SegmentedControl() {
|
||||
function StyledSegmentedControl() {
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<FormSegmentedControl name="name" options={options} />
|
||||
<SegmentedControl name="name" options={options} />
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ function Collection() {
|
||||
<div className="flex flex-col items-start gap-y-4 p-4">
|
||||
<ToastUI variant="success" message="Success UI" t="-" />
|
||||
<ToastUI variant="error" message="Error UI" t="-" />
|
||||
<ToastUI
|
||||
variant="success"
|
||||
message="This is a long error message that wraps over multiple lines so we can test the UI."
|
||||
t="-"
|
||||
/>
|
||||
<br />
|
||||
<Button
|
||||
variant="primary/large"
|
||||
@@ -41,6 +46,7 @@ function Collection() {
|
||||
>
|
||||
Error
|
||||
</Button>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { UsageBar } from "../billing/UsageBar";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
const meta: Meta<typeof UsageProgressBar> = {
|
||||
title: "Billing/UsageBar",
|
||||
component: UsageProgressBar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof UsageProgressBar>;
|
||||
|
||||
export const JobsUsageBar: Story = {
|
||||
render: () => <UsageProgressBar />,
|
||||
};
|
||||
|
||||
function UsageProgressBar() {
|
||||
return (
|
||||
<div className="flex flex-col justify-center gap-4 p-12">
|
||||
<UsageBarWrapper title="Usage within the free tier limit">
|
||||
<UsageBar numberOfCurrentRuns={30000} tierRunLimit={50000} projectedRuns={120000} />
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Usage over the free tier limit">
|
||||
<UsageBar numberOfCurrentRuns={90000} tierRunLimit={50000} projectedRuns={120000} />
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Billing limit set">
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={35674}
|
||||
tierRunLimit={50000}
|
||||
projectedRuns={120000}
|
||||
billingLimit={180000}
|
||||
/>
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Paid subscriber under the free included Runs">
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={10000}
|
||||
tierRunLimit={50000}
|
||||
billingLimit={180000}
|
||||
projectedRuns={120000}
|
||||
subscribedToPaidTier
|
||||
/>
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Paid subscriber over the free included Runs">
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={90000}
|
||||
tierRunLimit={50000}
|
||||
billingLimit={180000}
|
||||
projectedRuns={120000}
|
||||
subscribedToPaidTier
|
||||
/>
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Brand new user usage">
|
||||
<UsageBar numberOfCurrentRuns={0} tierRunLimit={50000} projectedRuns={0} />
|
||||
</UsageBarWrapper>
|
||||
<UsageBarWrapper title="Overlapping UI example">
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={95000}
|
||||
tierRunLimit={50000}
|
||||
billingLimit={55000}
|
||||
projectedRuns={93132}
|
||||
subscribedToPaidTier
|
||||
/>
|
||||
</UsageBarWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageBarWrapper({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border p-6">
|
||||
<Paragraph>{title}</Paragraph>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -110,13 +110,19 @@ function getClient() {
|
||||
// emit: "stdout",
|
||||
// level: "query",
|
||||
// },
|
||||
// {
|
||||
// emit: "event",
|
||||
// level: "query",
|
||||
// },
|
||||
],
|
||||
});
|
||||
|
||||
// client.$on("query", (e) => {
|
||||
// console.log("Query: " + e.query);
|
||||
// console.log("Params: " + e.params);
|
||||
// console.log("Duration: " + e.duration + "ms");
|
||||
// console.log(`Query tooks ${e.duration}ms`, {
|
||||
// query: e.query,
|
||||
// params: e.params,
|
||||
// duration: e.duration,
|
||||
// });
|
||||
// });
|
||||
|
||||
// connect eagerly
|
||||
|
||||
@@ -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,24 @@ 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),
|
||||
|
||||
TUNNEL_HOST: z.string().optional(),
|
||||
TUNNEL_SECRET_KEY: z.string().optional(),
|
||||
|
||||
//v3
|
||||
V3_ENABLED: z.string().default("false"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -12,6 +12,7 @@ export function featuresForRequest(request: Request): TriggerFeatures {
|
||||
const isManagedCloud =
|
||||
url.host === "cloud.trigger.dev" ||
|
||||
url.host === "test-cloud.trigger.dev" ||
|
||||
url.host === "internal.trigger.dev" ||
|
||||
process.env.CLOUD_ENV === "development";
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useNewCustomerSubscribed() {
|
||||
useEffect(() => {
|
||||
if ("confetti" in window && typeof window.confetti !== "undefined") {
|
||||
const duration = 3.5 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = {
|
||||
startVelocity: 30,
|
||||
spread: 360,
|
||||
ticks: 60,
|
||||
zIndex: 0,
|
||||
colors: [
|
||||
"#E7FF52",
|
||||
"#41FF54",
|
||||
"rgb(245 158 11)",
|
||||
"rgb(22 163 74)",
|
||||
"rgb(37 99 235)",
|
||||
"rgb(67 56 202)",
|
||||
"rgb(219 39 119)",
|
||||
"rgb(225 29 72)",
|
||||
"rgb(217 70 239)",
|
||||
],
|
||||
};
|
||||
function randomInRange(min: number, max: number): number {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
// @ts-ignore
|
||||
const interval = setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
|
||||
const particleCount = 60 * (timeLeft / duration);
|
||||
// since particles fall down, start a bit higher than random
|
||||
// @ts-ignore
|
||||
window.confetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.4), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
// @ts-ignore
|
||||
window.confetti(
|
||||
Object.assign({}, defaults, {
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.6, 0.9), y: Math.random() - 0.2 },
|
||||
})
|
||||
);
|
||||
}, 250);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
|
||||
export function useOptimisticLocation() {
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
|
||||
if (navigation.state === "idle" || !navigation.location) {
|
||||
return location;
|
||||
}
|
||||
|
||||
return navigation.location;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { UseDataFunctionReturn, useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { hydrateObject, useMatchesData } from "~/utils";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedOrganization = UseDataFunctionReturn<typeof orgLoader>["organizations"][number];
|
||||
|
||||
@@ -17,7 +17,7 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
|
||||
if (postHogInitialized.current === true) return;
|
||||
if (logging) console.log("Initializing PostHog");
|
||||
posthog.init(apiKey, {
|
||||
api_host: "https://app.posthog.com",
|
||||
api_host: "https://eu.posthog.com",
|
||||
opt_in_site_apps: true,
|
||||
debug,
|
||||
loaded: function (posthog) {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
|
||||
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
|
||||
|
||||
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
|
||||
export type MatchedProject = UseDataFunctionReturn<typeof orgLoader>["project"];
|
||||
|
||||
export function useOptionalProject(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: projectMatchId,
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { User } from "~/models/user.server";
|
||||
import { useMatchesData } from "~/utils";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function findEnvironmentByApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -28,24 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = organization.environments.find((environment) => environment.slug === slug);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -139,7 +148,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#logDebug("Initializing worker queue with options", {
|
||||
this.#logDebug("Initializing graphile worker queue with options", {
|
||||
runnerOptions: this.#runnerOptions,
|
||||
});
|
||||
|
||||
@@ -150,10 +159,11 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
noHandleSignals: true,
|
||||
taskList: this.#createTaskListFromTasks(),
|
||||
parsedCronItems,
|
||||
forbiddenFlags: this.#rateLimiter?.forbiddenFlags.bind(this.#rateLimiter),
|
||||
});
|
||||
|
||||
if (!this.#runner) {
|
||||
throw new Error("Failed to initialize worker queue");
|
||||
throw new Error("Failed to initialize graphile worker queue");
|
||||
}
|
||||
|
||||
this.#runner?.events.on("pool:create", ({ workerPool }) => {
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ClientEndpoint =
|
||||
state: "configured";
|
||||
id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
url: string | null;
|
||||
indexWebhookPath: string;
|
||||
latestIndex?: {
|
||||
status: EndpointIndexStatus;
|
||||
@@ -102,6 +102,11 @@ export class EnvironmentsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
url: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Direction, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
type EventListOptions = {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
direction?: Direction;
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type EventList = Awaited<ReturnType<EventListPresenter["call"]>>;
|
||||
|
||||
export class EventListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
filterEnvironment,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
from,
|
||||
to,
|
||||
}: EventListOptions) {
|
||||
const directionMultiplier = direction === "forward" ? 1 : -1;
|
||||
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await this.#prismaClient.eventRecord.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deliverAt: true,
|
||||
deliveredAt: true,
|
||||
isTest: true,
|
||||
createdAt: true,
|
||||
cancelledAt: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
name: {
|
||||
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
|
||||
},
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
createdAt: {
|
||||
gte: from ? new Date(from).toISOString() : undefined,
|
||||
lte: to ? new Date(to).toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//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,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const hasMore = events.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? events.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
previous = events[1]?.id;
|
||||
next = events[pageSize]?.id;
|
||||
} else {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const eventsToReturn =
|
||||
direction === "backward" && hasMore
|
||||
? events.slice(1, pageSize + 1)
|
||||
: events.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
events: eventsToReturn.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
deliverAt: event.deliverAt,
|
||||
deliveredAt: event.deliveredAt,
|
||||
createdAt: event.createdAt,
|
||||
cancelledAt: event.cancelledAt,
|
||||
isTest: event.isTest,
|
||||
environment: {
|
||||
type: event.environment.type,
|
||||
slug: event.environment.slug,
|
||||
userId: event.environment.orgMember?.user.id,
|
||||
userName: getUsername(event.environment.orgMember?.user),
|
||||
},
|
||||
runs: event.runs.length,
|
||||
})),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
|
||||
export type Event = NonNullable<Awaited<ReturnType<EventPresenter["call"]>>>;
|
||||
|
||||
export class EventPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
eventId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
eventId: string;
|
||||
}) {
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
|
||||
const event = await this.#prismaClient.eventRecord.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
payload: true,
|
||||
context: true,
|
||||
timestamp: true,
|
||||
deliveredAt: true,
|
||||
},
|
||||
where: {
|
||||
id: eventId,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new Error("Could not find Event");
|
||||
}
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
timestamp: event.timestamp,
|
||||
payload: JSON.stringify(event.payload, null, 2),
|
||||
context: JSON.stringify(event.context, null, 2),
|
||||
deliveredAt: event.deliveredAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { z } from "zod";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
import { JobRunStatus } from "@trigger.dev/database";
|
||||
|
||||
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
|
||||
|
||||
@@ -43,52 +44,34 @@ export class JobListPresenter {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
integrations: {
|
||||
select: {
|
||||
version: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 1,
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
@@ -115,50 +98,47 @@ export class JobListPresenter {
|
||||
orderBy: [{ title: "asc" }],
|
||||
});
|
||||
|
||||
let latestRuns = [] as {
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[];
|
||||
|
||||
if (jobs.length > 0) {
|
||||
latestRuns = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
"id",
|
||||
"createdAt",
|
||||
"status",
|
||||
"jobId",
|
||||
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
|
||||
FROM
|
||||
"JobRun"
|
||||
WHERE
|
||||
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
|
||||
) t
|
||||
WHERE rn = 1;`;
|
||||
}
|
||||
|
||||
return jobs
|
||||
.map((job) => {
|
||||
//the best alias to select:
|
||||
// 1. Logged-in user dev
|
||||
// 2. Prod
|
||||
// 3. Any other user's dev
|
||||
const sortedAliases = job.aliases.sort((a, b) => {
|
||||
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.environment.type === "PRODUCTION") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "PRODUCTION") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const alias = sortedAliases.at(0);
|
||||
|
||||
if (!alias) {
|
||||
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
|
||||
.flatMap((job) => {
|
||||
const version = job.versions.at(0);
|
||||
if (!version) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
|
||||
const eventSpecification = EventSpecificationSchema.parse(version.eventSpecification);
|
||||
|
||||
const lastRuns = job.aliases
|
||||
.map((alias) => alias.version.runs.at(0))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
|
||||
const lastRun = lastRuns.at(0);
|
||||
|
||||
const integrations = alias.version.integrations.map((integration) => ({
|
||||
const integrations = job.integrations.map((integration) => ({
|
||||
key: integration.key,
|
||||
title: integration.integration.slug,
|
||||
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
|
||||
@@ -171,44 +151,41 @@ export class JobListPresenter {
|
||||
properties = [...properties, ...eventSpecification.properties];
|
||||
}
|
||||
|
||||
if (alias.version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
|
||||
if (version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(version.properties);
|
||||
properties = [...properties, ...versionProperties];
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
}));
|
||||
const latestRun = latestRuns.find((r) => r.jobId === job.id);
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: alias.version.version,
|
||||
status: alias.version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
alias.version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
return [
|
||||
{
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
environment: version.environment,
|
||||
lastRun: latestRun,
|
||||
properties,
|
||||
projectSlug: job.project.slug,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
lastRun,
|
||||
properties,
|
||||
environments,
|
||||
projectSlug: job.project.slug,
|
||||
};
|
||||
];
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -10,11 +10,16 @@ export class NewOrganizationPresenter {
|
||||
|
||||
public async call({ userId }: { userId: User["id"] }) {
|
||||
const organizations = await this.#prismaClient.organization.findMany({
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
where: { members: { some: { userId } } },
|
||||
});
|
||||
|
||||
return {
|
||||
hasOrganizations: organizations.length > 0,
|
||||
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BillingService } from "../services/billing.server";
|
||||
|
||||
export class OrgBillingPlanPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ slug, isManagedCloud }: { slug: string; isManagedCloud: boolean }) {
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
const plans = await billingPresenter.getPlans();
|
||||
|
||||
if (plans === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organization = await this.#prismaClient.organization.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxConcurrency = await this.#prismaClient.$queryRaw<
|
||||
{ organization_id: string; max_concurrent_runs: BigInt }[]
|
||||
>`WITH events AS (
|
||||
SELECT
|
||||
re.event_time,
|
||||
re.organization_id,
|
||||
re.event_type,
|
||||
SUM(re.event_type) OVER (PARTITION BY re.organization_id ORDER BY re.event_time) AS running_total
|
||||
FROM
|
||||
triggerdotdev_events.run_executions re
|
||||
WHERE
|
||||
re.organization_id = ${organization.id}
|
||||
AND re.event_time >= DATE_TRUNC('month',
|
||||
CURRENT_DATE)
|
||||
)
|
||||
SELECT
|
||||
organization_id, MAX(running_total) AS max_concurrent_runs
|
||||
FROM
|
||||
events
|
||||
GROUP BY
|
||||
organization_id;`;
|
||||
|
||||
return {
|
||||
plans,
|
||||
maxConcurrency:
|
||||
maxConcurrency.at(0) !== undefined
|
||||
? Number(maxConcurrency[0].max_concurrent_runs)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { estimate } from "@trigger.dev/billing";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
|
||||
export class OrgUsagePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -8,7 +10,7 @@ export class OrgUsagePresenter {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, slug }: { userId: string; slug: string }) {
|
||||
public async call({ userId, slug, request }: { userId: string; slug: string; request: Request }) {
|
||||
const organization = await this.#prismaClient.organization.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
@@ -21,12 +23,9 @@ export class OrgUsagePresenter {
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
||||
const startOfLastMonth = new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1); // this works for January as well
|
||||
|
||||
// Get count of runs since the start of the current month
|
||||
const runsCount = await this.#prismaClient.jobRun.count({
|
||||
where: {
|
||||
@@ -38,18 +37,6 @@ export class OrgUsagePresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Get the count of runs for last month
|
||||
const runsCountLastMonth = await this.#prismaClient.jobRun.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
createdAt: {
|
||||
gte: startOfLastMonth,
|
||||
lt: startOfMonth,
|
||||
},
|
||||
internal: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the count of the runs for the last 6 months, by month. So for example we want the data shape to be:
|
||||
// [
|
||||
// { month: "2021-01", count: 10 },
|
||||
@@ -61,100 +48,130 @@ export class OrgUsagePresenter {
|
||||
// ]
|
||||
// This will be used to generate the chart on the usage page
|
||||
// Use prisma queryRaw for this since prisma doesn't support grouping by month
|
||||
const chartDataRaw = await this.#prismaClient.$queryRaw<
|
||||
const monthlyRunsDataRaw = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
month: string;
|
||||
count: number;
|
||||
}[]
|
||||
>`SELECT TO_CHAR("createdAt", 'YYYY-MM') as month, COUNT(*) as count FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '6 months' AND "internal" = FALSE GROUP BY month ORDER BY month ASC`;
|
||||
|
||||
const chartData = chartDataRaw.map((obj) => ({
|
||||
const hasMonthlyRunData = monthlyRunsDataRaw.length > 0;
|
||||
const monthlyRunsData = monthlyRunsDataRaw.map((obj) => ({
|
||||
name: obj.month,
|
||||
total: Number(obj.count), // Convert BigInt to Number
|
||||
}));
|
||||
|
||||
const totalJobs = await this.#prismaClient.job.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
internal: false,
|
||||
},
|
||||
});
|
||||
const monthlyRunsDataDisplay = fillInMissingRunMonthlyData(monthlyRunsData, 6);
|
||||
|
||||
const totalJobsLastMonth = await this.#prismaClient.job.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
createdAt: {
|
||||
lt: startOfMonth,
|
||||
},
|
||||
deletedAt: null,
|
||||
internal: false,
|
||||
},
|
||||
});
|
||||
// Max concurrency each day over past 30 days
|
||||
const concurrencyChartRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; max_concurrent_runs: BigInt }[]
|
||||
>`
|
||||
WITH time_boundaries AS (
|
||||
SELECT generate_series(
|
||||
NOW() - interval '30 days',
|
||||
NOW(),
|
||||
interval '1 day'
|
||||
) AS day_start
|
||||
),
|
||||
events AS (
|
||||
SELECT
|
||||
day_start,
|
||||
event_time,
|
||||
event_type,
|
||||
SUM(event_type) OVER (ORDER BY event_time) AS running_total
|
||||
FROM
|
||||
time_boundaries
|
||||
JOIN
|
||||
triggerdotdev_events.run_executions
|
||||
ON
|
||||
event_time >= day_start AND event_time < day_start + interval '1 day'
|
||||
WHERE triggerdotdev_events.run_executions.organization_id = ${organization.id}
|
||||
),
|
||||
max_concurrent_per_day AS (
|
||||
SELECT
|
||||
date_trunc('day', event_time) AS day,
|
||||
MAX(running_total) AS max_concurrent_runs
|
||||
FROM
|
||||
events
|
||||
GROUP BY day
|
||||
)
|
||||
SELECT
|
||||
day,
|
||||
max_concurrent_runs
|
||||
FROM
|
||||
max_concurrent_per_day
|
||||
ORDER BY
|
||||
day;`;
|
||||
|
||||
const totalIntegrations = await this.#prismaClient.integration.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const ThirtyDaysAgo = new Date();
|
||||
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
|
||||
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const totalIntegrationsLastMonth = await this.#prismaClient.integration.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
createdAt: {
|
||||
lt: startOfMonth,
|
||||
},
|
||||
},
|
||||
});
|
||||
const hasConcurrencyData = concurrencyChartRawData.length > 0;
|
||||
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
|
||||
ThirtyDaysAgo,
|
||||
31,
|
||||
concurrencyChartRawData
|
||||
);
|
||||
|
||||
const totalMembers = await this.#prismaClient.orgMember.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; runs: BigInt }[]
|
||||
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
|
||||
|
||||
const jobs = await this.#prismaClient.job.findMany({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
deletedAt: null,
|
||||
internal: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
_count: {
|
||||
select: {
|
||||
runs: {
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startOfMonth,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const hasDailyRunsData = dailyRunsRawData.length > 0;
|
||||
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
|
||||
|
||||
const chartDataDisplay = fillInMissingMonthlyData(chartData, 6);
|
||||
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
endOfMonth.setDate(endOfMonth.getDate() - 1);
|
||||
const projectedRunsCount = Math.round(
|
||||
runsCount / (new Date().getDate() / endOfMonth.getDate())
|
||||
);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
const plans = await billingPresenter.getPlans();
|
||||
|
||||
let runCostEstimation: number | undefined = undefined;
|
||||
let projectedRunCostEstimation: number | undefined = undefined;
|
||||
|
||||
if (plans) {
|
||||
const estimationResult = estimate({
|
||||
usage: { runs: runsCount },
|
||||
plans: [plans.free, plans.paid],
|
||||
});
|
||||
runCostEstimation = estimationResult?.cost.runsCost;
|
||||
|
||||
const projectedEstimationResult = estimate({
|
||||
usage: { runs: projectedRunsCount },
|
||||
plans: [plans.free, plans.paid],
|
||||
});
|
||||
projectedRunCostEstimation = projectedEstimationResult?.cost.runsCost;
|
||||
}
|
||||
|
||||
const periodStart = new Date();
|
||||
periodStart.setDate(1);
|
||||
periodStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setDate(1);
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
periodEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
return {
|
||||
id: organization.id,
|
||||
runsCount,
|
||||
runsCountLastMonth,
|
||||
chartData: chartDataDisplay,
|
||||
totalJobs,
|
||||
totalJobsLastMonth,
|
||||
totalIntegrations,
|
||||
totalIntegrationsLastMonth,
|
||||
totalMembers,
|
||||
jobs,
|
||||
projectedRunsCount,
|
||||
monthlyRunsData: monthlyRunsDataDisplay,
|
||||
hasMonthlyRunData,
|
||||
concurrencyData: concurrencyChartRawDataFilledIn,
|
||||
hasConcurrencyData,
|
||||
dailyRunsData: dailyRunsDataFilledIn,
|
||||
hasDailyRunsData,
|
||||
runCostEstimation,
|
||||
projectedRunCostEstimation,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -163,7 +180,7 @@ export class OrgUsagePresenter {
|
||||
// So for example, if data is [{ name: "2021-01", total: 10 }, { name: "2021-03", total: 30 }] and the totalNumberOfMonths is 6
|
||||
// And the current month is "2021-04", then this function will return:
|
||||
// [{ name: "2020-11", total: 0 }, { name: "2020-12", total: 0 }, { name: "2021-01", total: 10 }, { name: "2021-02", total: 0 }, { name: "2021-03", total: 30 }, { name: "2021-04", total: 0 }]
|
||||
function fillInMissingMonthlyData(
|
||||
function fillInMissingRunMonthlyData(
|
||||
data: Array<{ name: string; total: number }>,
|
||||
totalNumberOfMonths: number
|
||||
): Array<{ name: string; total: number }> {
|
||||
@@ -187,6 +204,60 @@ function fillInMissingMonthlyData(
|
||||
return completeData;
|
||||
}
|
||||
|
||||
function fillInMissingConcurrencyDays(
|
||||
startDate: Date,
|
||||
days: number,
|
||||
data: Array<{ day: Date; max_concurrent_runs: BigInt }>
|
||||
) {
|
||||
const outputData: Array<{ date: Date; maxConcurrentRuns: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
date,
|
||||
maxConcurrentRuns: 0,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
date,
|
||||
maxConcurrentRuns: Number(foundData.max_concurrent_runs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputData;
|
||||
}
|
||||
|
||||
function fillInMissingDailyRuns(
|
||||
startDate: Date,
|
||||
days: number,
|
||||
data: Array<{ day: Date; runs: BigInt }>
|
||||
) {
|
||||
const outputData: Array<{ date: Date; runs: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: 0,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: Number(foundData.runs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputData;
|
||||
}
|
||||
|
||||
// Start month will be like 2023-03 and endMonth will be like 2023-10
|
||||
// The result should be an array of months between these two months, including the start and end month
|
||||
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
|
||||
@@ -212,11 +283,3 @@ function getMonthsBetween(startMonth: string, endMonth: string): string[] {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLastSecondOfMonth(endMonth: string) {
|
||||
const [year, month] = endMonth.split("-").map(Number);
|
||||
const nextMonthFirstDay = new Date(year, month, 1);
|
||||
nextMonthFirstDay.setDate(0);
|
||||
nextMonthFirstDay.setHours(23, 59, 59);
|
||||
return nextMonthFirstDay;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
setCurrentProjectId,
|
||||
} from "~/services/currentProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
|
||||
import { newProjectPath } from "~/utils/pathBuilder";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { match } from "assert";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -16,40 +23,167 @@ export class OrganizationsPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
request,
|
||||
projectSlug,
|
||||
request,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string | undefined;
|
||||
request: Request;
|
||||
projectSlug?: string;
|
||||
}) {
|
||||
const organizations = await this.getOrganizations(userId);
|
||||
//first get the project id, this redirects if there's no session
|
||||
const projectId = await this.#getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
const organizations = await this.#getOrganizations(userId);
|
||||
const organization = organizations.find((o) => o.slug === organizationSlug);
|
||||
if (!organization) {
|
||||
logger.info("Not Found: organization", {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
request,
|
||||
organization,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const project = await this.getProject(organization, projectSlug, request, userId);
|
||||
const projectPresenter = new ProjectPresenter(this.#prismaClient);
|
||||
const project = await projectPresenter.call({
|
||||
id: projectId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects found in organization"
|
||||
);
|
||||
}
|
||||
|
||||
return { organizations, organization, project };
|
||||
}
|
||||
|
||||
async getOrganizations(userId: string) {
|
||||
async #getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
}: {
|
||||
request: Request;
|
||||
projectSlug: string | undefined;
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
}): Promise<string> {
|
||||
const sessionProjectId = await getCurrentProjectId(request);
|
||||
|
||||
//no project in session, let's set one
|
||||
if (!sessionProjectId) {
|
||||
//no session id and no project slug so we need to select the best project
|
||||
if (!projectSlug) {
|
||||
const bestProject = await this.#selectBestProjectForOrganization(
|
||||
organizationSlug,
|
||||
userId,
|
||||
request
|
||||
);
|
||||
const session = await setCurrentProjectId(bestProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
//get all the projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
slug: projectSlug,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects in this organization"
|
||||
);
|
||||
}
|
||||
|
||||
//try get the project which matches the URL
|
||||
let matchingProject = projects.find((p) => p.slug === projectSlug);
|
||||
|
||||
//if there's no matching project, just use the most recently updated one
|
||||
if (!matchingProject) {
|
||||
matchingProject = projects[0];
|
||||
}
|
||||
|
||||
//set the session
|
||||
const session = await setCurrentProjectId(matchingProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
if (!projectSlug) {
|
||||
return sessionProjectId;
|
||||
}
|
||||
|
||||
//check session id matches the project slug
|
||||
const project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found in organization", { status: 404 });
|
||||
}
|
||||
|
||||
if (project.id !== sessionProjectId) {
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async #getOrganizations(userId: string) {
|
||||
const orgs = await this.#prismaClient.organization.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
where: { members: { some: { userId } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
runsEnabled: true,
|
||||
projects: {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
where: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
jobs: {
|
||||
@@ -67,10 +201,10 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
@@ -93,38 +227,41 @@ export class OrganizationsPresenter {
|
||||
jobCount: project._count.jobs,
|
||||
})),
|
||||
hasUnconfiguredIntegrations: org._count.integrations > 0,
|
||||
memberCount: org._count.members,
|
||||
runsEnabled: org.runsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getProject(
|
||||
organization: Org,
|
||||
projectSlug: string | undefined,
|
||||
request: Request,
|
||||
userId: string
|
||||
async #selectBestProjectForOrganization(
|
||||
organizationSlug: string,
|
||||
userId: string,
|
||||
request: Request
|
||||
) {
|
||||
const projectPresenter = new ProjectPresenter();
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
jobs: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!projectSlug) {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
const orgProject = organization.projects.find((p) => p.id === projectId);
|
||||
if (!orgProject) {
|
||||
logger.info("Not Found: proj 1", {
|
||||
projectId,
|
||||
organization,
|
||||
projectSlug: projectSlug ?? null,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
projectSlug = orgProject.slug;
|
||||
if (projects.length === 0) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
|
||||
const project = await projectPresenter.call({ userId, slug: projectSlug });
|
||||
if (!project) {
|
||||
logger.info("Not Found: proj 2", { projectSlug, organization, project });
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return project;
|
||||
return projects[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class ProjectPresenter {
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
slug,
|
||||
}: Pick<Project, "slug"> & {
|
||||
id,
|
||||
}: Pick<Project, "id"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
@@ -23,67 +23,7 @@ export class ProjectPresenter {
|
||||
organizationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
jobs: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ title: "asc" }],
|
||||
},
|
||||
deletedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
sources: {
|
||||
@@ -100,19 +40,6 @@ export class ProjectPresenter {
|
||||
httpEndpoints: true,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -127,7 +54,7 @@ export class ProjectPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { slug, organization: { members: { some: { userId } } } },
|
||||
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
@@ -141,6 +68,7 @@ export class ProjectPresenter {
|
||||
organizationId: project.organizationId,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
deletedAt: project.deletedAt,
|
||||
hasInactiveExternalTriggers: project._count.sources > 0,
|
||||
jobCount: project._count.jobs,
|
||||
httpEndpointCount: project._count.httpEndpoints,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user