Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac524f0678 | |||
| bd7535b4e7 | |||
| 3ab64ea935 | |||
| 9846e74d74 | |||
| 61b7f5cd2a | |||
| 2f318cad41 | |||
| 7b479da51d | |||
| ec1b704003 | |||
| 3750bca5c2 | |||
| 0fab371ee4 | |||
| a215359dba | |||
| c3f18fd38a | |||
| c41cf7e9cf | |||
| e614d29bc5 | |||
| 8997363e72 | |||
| 01efca816b | |||
| 735182e3e2 | |||
| 2a1273b683 | |||
| 6a02d6aa53 | |||
| 64401ee8ec | |||
| b7845685eb | |||
| 52c9d485f8 | |||
| ca47e6bdcd | |||
| 933cf0f119 | |||
| b6517af522 | |||
| 479445f741 | |||
| 1fabcf68fa | |||
| d7c79ee651 | |||
| 8d6e710dc2 | |||
| 90a302f9f7 | |||
| 07efef405d | |||
| dd63fe6e9f | |||
| d8c645a3f0 | |||
| 24600d2c01 | |||
| 584936fa29 | |||
| a54bdb23bf | |||
| 07f7c65054 | |||
| d04c8e89f6 | |||
| b69902dd84 | |||
| 2aabb994cf | |||
| 310d51bac0 | |||
| 6be1099a12 | |||
| baf3a84cda | |||
| 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 |
@@ -14,6 +14,8 @@
|
||||
"ignore": [
|
||||
"webapp",
|
||||
"emails",
|
||||
"proxy",
|
||||
"yalt",
|
||||
"@trigger.dev/database"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
|
||||
+8
-1
@@ -31,6 +31,9 @@ NODE_ENV=development
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
|
||||
# Remove the following line to enable logging telemetry traces to the console
|
||||
LOG_TELEMETRY="false"
|
||||
|
||||
# CLOUD VARIABLES
|
||||
POSTHOG_PROJECT_KEY=
|
||||
PLAIN_API_KEY=
|
||||
@@ -42,4 +45,8 @@ CLOUD_LINEAR_CLIENT_ID=
|
||||
CLOUD_LINEAR_CLIENT_SECRET=
|
||||
CLOUD_SLACK_APP_HOST=
|
||||
CLOUD_SLACK_CLIENT_ID=
|
||||
CLOUD_SLACK_CLIENT_SECRET=
|
||||
CLOUD_SLACK_CLIENT_SECRET=
|
||||
|
||||
# v3 variables
|
||||
PROVIDER_SECRET=provider-secret # generate the actual secret with `openssl rand -hex 32`
|
||||
COORDINATOR_SECRET=coordinator-secret # generate the actual secret with `openssl rand -hex 32`
|
||||
@@ -7,16 +7,16 @@ 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 }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME || vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN || vars.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
@@ -29,6 +29,11 @@ jobs:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -43,7 +48,10 @@ jobs:
|
||||
|
||||
# Build packages
|
||||
pnpm run build --filter @references/nextjs-test^...
|
||||
cd apps/webapp && pnpm run build:server
|
||||
cd ../..
|
||||
pnpm --filter @trigger.dev/database generate
|
||||
pnpm --filter @trigger.dev/otlp-importer generate
|
||||
|
||||
# Move trigger-cli bin to correct place
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: "🚢 Publish Container Images (dev)"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "dev-*"
|
||||
paths:
|
||||
- ".github/workflows/publish.yml"
|
||||
- "packages/**"
|
||||
- "!packages/**/*.md"
|
||||
- "!packages/**/*.eslintrc"
|
||||
- "apps/**"
|
||||
- "!apps/**/*.md"
|
||||
- "!apps/**/*.eslintrc"
|
||||
- "integrations/**"
|
||||
- "!integrations/**/*.md"
|
||||
- "!integrations/**/*.eslintrc"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- "docker/Dockerfile"
|
||||
- "docker/scripts/**"
|
||||
- "tests/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
package: [coordinator, kubernetes-provider]
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Generate build ID
|
||||
id: prep
|
||||
run: |
|
||||
sha=${GITHUB_SHA::7}
|
||||
ts=$(date +%s)
|
||||
echo "BUILD_ID=${{ matrix.package }}-${sha}-${ts}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: 🚢 Build Container Image
|
||||
run: |
|
||||
docker build -t dev_image -f ./apps/${{ matrix.package }}/Containerfile .
|
||||
|
||||
# ..to push image
|
||||
- name: 🐙 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 🐙 Push to GitHub Container Registry
|
||||
run: |
|
||||
docker tag dev_image $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: dev
|
||||
IMAGE_TAG: ${{ steps.prep.outputs.BUILD_ID }}
|
||||
@@ -3,7 +3,12 @@ on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
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 }}
|
||||
@@ -15,6 +20,8 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: 🆚 Get the version
|
||||
id: get_version
|
||||
@@ -36,6 +43,9 @@ jobs:
|
||||
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
|
||||
@@ -65,8 +75,12 @@ jobs:
|
||||
- name: 🐙 Push 'latest' to GitHub Container Registry
|
||||
if: startsWith(github.ref, 'refs/tags/v.docker')
|
||||
run: |
|
||||
docker tag release_build_image $REGISTRY/$REPOSITORY:latest
|
||||
docker push $REGISTRY/$REPOSITORY:latest
|
||||
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
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -39,6 +38,11 @@ jobs:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ jobs:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ jobs:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: ⎔ Setup Deno
|
||||
uses: denoland/setup-deno@v1
|
||||
with:
|
||||
|
||||
+2
-1
@@ -53,4 +53,5 @@ apps/**/public/build
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.cosine
|
||||
.cosine
|
||||
.trigger/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "packages/otlp-importer/protos"]
|
||||
path = packages/otlp-importer/protos
|
||||
url = https://github.com/open-telemetry/opentelemetry-proto.git
|
||||
Vendored
+17
-1
@@ -9,7 +9,7 @@
|
||||
"request": "launch",
|
||||
"name": "Debug WebApp",
|
||||
"command": "pnpm run dev --filter webapp",
|
||||
"envFile": "${workspaceFolder}/apps/webapp/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
@@ -28,6 +28,22 @@
|
||||
"envFile": "${workspaceFolder}/references/job-catalog/.env",
|
||||
"cwd": "${workspaceFolder}/references/job-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Dev CLI",
|
||||
"command": "pnpm exec trigger.dev dev",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "Attach to Trigger.dev CLI (v3)",
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
HTTP_SERVER_PORT=8020
|
||||
|
||||
PLATFORM_ENABLED=true
|
||||
PLATFORM_WS_PORT=3030
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
@@ -0,0 +1,63 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18.18.2-bullseye-slim@sha256:21479df46c3173ee0cefc6b264928e10239152c4f74df872ca9369be01a245b7 AS node-18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18 AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=coordinator --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18 AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
FROM base AS dev-deps
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV development
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN corepack enable
|
||||
|
||||
COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter '@trigger.dev/core*' build
|
||||
RUN pnpm run -r --filter coordinator build:bundle
|
||||
|
||||
FROM alpine AS cri-tools
|
||||
|
||||
WORKDIR /cri-tools
|
||||
|
||||
ARG CRICTL_VERSION=v1.29.0
|
||||
ARG CRICTL_CHECKSUM=sha256:d16a1ffb3938f5a19d5c8f45d363bd091ef89c0bc4d44ad16b933eede32fdcbb
|
||||
ADD --checksum=${CRICTL_CHECKSUM} \
|
||||
https://github.com/kubernetes-sigs/cri-tools/releases/download/${CRICTL_VERSION}/crictl-${CRICTL_VERSION}-linux-amd64.tar.gz .
|
||||
RUN tar zxvf crictl-${CRICTL_VERSION}-linux-amd64.tar.gz
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=cri-tools --chown=node:node /cri-tools/crictl /usr/local/bin
|
||||
COPY --from=builder --chown=node:node /app/apps/coordinator/dist/index.mjs ./index.mjs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
USER node
|
||||
|
||||
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.mjs" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Coordinator
|
||||
|
||||
Sits between the platform and tasks. Facilitates communication and checkpointing, amongst other things.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "coordinator",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:bundle",
|
||||
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.mjs --platform=node --format=esm --target=esnext --banner:js=\"const require = createRequire(import.meta.url);\"",
|
||||
"build:image": "docker build -f Containerfile . -t coordinator",
|
||||
"dev": "tsx --no-warnings=ExperimentalWarning --require dotenv/config --watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-apps": "workspace:*",
|
||||
"execa": "^8.0.1",
|
||||
"prom-client": "^15.1.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"socket.io-client": "^4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18",
|
||||
"dotenv": "^16.4.2",
|
||||
"esbuild": "^0.19.11",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { $ } from "execa";
|
||||
import { Server } from "socket.io";
|
||||
import {
|
||||
CoordinatorToPlatformMessages,
|
||||
CoordinatorToProdWorkerMessages,
|
||||
PlatformToCoordinatorMessages,
|
||||
ProdWorkerSocketData,
|
||||
ProdWorkerToCoordinatorMessages,
|
||||
ZodNamespace,
|
||||
ZodSocketConnection,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { HttpReply, getTextBody, SimpleLogger } from "@trigger.dev/core-apps";
|
||||
|
||||
import { collectDefaultMetrics, register, Gauge } from "prom-client";
|
||||
collectDefaultMetrics();
|
||||
|
||||
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020);
|
||||
const NODE_NAME = process.env.NODE_NAME || "coordinator";
|
||||
const REGISTRY_FQDN = process.env.REGISTRY_FQDN || "localhost:5000";
|
||||
const REPO_NAME = process.env.REPO_NAME || "checkpoints";
|
||||
const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints";
|
||||
const REGISTRY_TLS_VERIFY = process.env.REGISTRY_TLS_VERIFY === "false" ? "false" : "true";
|
||||
|
||||
const PLATFORM_ENABLED = ["1", "true"].includes(process.env.PLATFORM_ENABLED ?? "true");
|
||||
const PLATFORM_HOST = process.env.PLATFORM_HOST || "127.0.0.1";
|
||||
const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 3030;
|
||||
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "coordinator-secret";
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
|
||||
class Checkpointer {
|
||||
#initialized = false;
|
||||
#canCheckpoint = false;
|
||||
#dockerMode = true;
|
||||
|
||||
#logger = new SimpleLogger("[checkptr]");
|
||||
|
||||
async initialize() {
|
||||
if (this.#initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await $`criu --version`;
|
||||
} catch (error) {
|
||||
this.#logger.error("No checkpoint support: Missing CRIU binary");
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#dockerMode) {
|
||||
try {
|
||||
await $`docker checkpoint`;
|
||||
} catch (error) {
|
||||
this.#logger.error(
|
||||
"No checkpoint support: Docker needs to have experimental features enabled"
|
||||
);
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.log(
|
||||
`Full checkpoint support with docker ${this.#dockerMode ? "enabled" : "disabled"}`
|
||||
);
|
||||
|
||||
this.#initialized = true;
|
||||
this.#canCheckpoint = true;
|
||||
}
|
||||
|
||||
async checkpointAndPush(podName: string) {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.#canCheckpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { path } = await this.#checkpointContainer(podName);
|
||||
const { tag } = await this.#buildImage(path, podName);
|
||||
const { destination } = await this.#pushImage(tag);
|
||||
|
||||
if (this.#dockerMode) {
|
||||
this.#logger.log("checkpoint created:", { podName, path });
|
||||
} else {
|
||||
this.#logger.log("checkpointed and pushed image to:", destination);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
tag,
|
||||
destination: this.#dockerMode ? path : destination,
|
||||
docker: this.#dockerMode,
|
||||
};
|
||||
} catch (error) {
|
||||
this.#logger.error("checkpoint failed", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async #checkpointContainer(podName: string) {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.#canCheckpoint) {
|
||||
throw new Error("No checkpoint support");
|
||||
}
|
||||
|
||||
if (this.#dockerMode) {
|
||||
this.#logger.log("Checkpointing:", podName);
|
||||
|
||||
const path = randomUUID();
|
||||
|
||||
try {
|
||||
this.#logger.debug(await $`docker checkpoint create --leave-running ${podName} ${path}`);
|
||||
} catch (error: any) {
|
||||
this.#logger.error(error.stderr);
|
||||
}
|
||||
|
||||
return { path };
|
||||
}
|
||||
|
||||
const containerId = this.#logger.debug(
|
||||
// @ts-expect-error
|
||||
await $`crictl ps`
|
||||
.pipeStdout($({ stdin: "pipe" })`grep ${podName}`)
|
||||
.pipeStdout($({ stdin: "pipe" })`cut -f1 ${"-d "}`)
|
||||
);
|
||||
|
||||
if (!containerId.stdout) {
|
||||
throw new Error("could not find container id");
|
||||
}
|
||||
|
||||
const exportPath = `${CHECKPOINT_PATH}/${podName}.tar`;
|
||||
|
||||
this.#logger.debug(await $`crictl checkpoint --export=${exportPath} ${containerId}`);
|
||||
|
||||
return {
|
||||
path: exportPath,
|
||||
};
|
||||
}
|
||||
|
||||
async #buildImage(checkpointPath: string, tag: string) {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.#canCheckpoint) {
|
||||
throw new Error("No checkpoint support");
|
||||
}
|
||||
|
||||
if (this.#dockerMode) {
|
||||
// Nothing to do here
|
||||
return { tag };
|
||||
}
|
||||
|
||||
const container = this.#logger.debug(await $`buildah from scratch`);
|
||||
this.#logger.debug(await $`buildah add ${container} ${checkpointPath} /`);
|
||||
this.#logger.debug(
|
||||
await $`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
|
||||
);
|
||||
this.#logger.debug(await $`buildah commit ${container} ${REGISTRY_FQDN}/${REPO_NAME}:${tag}`);
|
||||
this.#logger.debug(await $`buildah rm ${container}`);
|
||||
|
||||
return {
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
async #pushImage(tag: string) {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.#canCheckpoint) {
|
||||
throw new Error("No checkpoint support");
|
||||
}
|
||||
|
||||
if (this.#dockerMode) {
|
||||
// Nothing to do here
|
||||
return { destination: "" };
|
||||
}
|
||||
|
||||
const destination = `${REGISTRY_FQDN}/${REPO_NAME}:${tag}`;
|
||||
this.#logger.debug(await $`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${destination}`);
|
||||
|
||||
return {
|
||||
destination,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TaskCoordinator {
|
||||
#httpServer: ReturnType<typeof createServer>;
|
||||
#checkpointer = new Checkpointer();
|
||||
|
||||
#prodWorkerNamespace: ZodNamespace<
|
||||
typeof ProdWorkerToCoordinatorMessages,
|
||||
typeof CoordinatorToProdWorkerMessages,
|
||||
typeof ProdWorkerSocketData
|
||||
>;
|
||||
#platformSocket?: ZodSocketConnection<
|
||||
typeof CoordinatorToPlatformMessages,
|
||||
typeof PlatformToCoordinatorMessages
|
||||
>;
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private host = "0.0.0.0"
|
||||
) {
|
||||
this.#httpServer = this.#createHttpServer();
|
||||
this.#checkpointer.initialize();
|
||||
|
||||
const io = new Server(this.#httpServer);
|
||||
this.#prodWorkerNamespace = this.#createProdWorkerNamespace(io);
|
||||
|
||||
this.#platformSocket = this.#createPlatformSocket();
|
||||
|
||||
const connectedTasksTotal = new Gauge({
|
||||
name: "daemon_connected_tasks_total", // don't change this without updating dashboard config
|
||||
help: "The number of tasks currently connected.",
|
||||
collect: () => {
|
||||
connectedTasksTotal.set(this.#prodWorkerNamespace.namespace.sockets.size);
|
||||
},
|
||||
});
|
||||
register.registerMetric(connectedTasksTotal);
|
||||
}
|
||||
|
||||
#createPlatformSocket() {
|
||||
if (!PLATFORM_ENABLED) {
|
||||
console.log("INFO: platform connection disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const platformConnection = new ZodSocketConnection({
|
||||
namespace: "coordinator",
|
||||
host: PLATFORM_HOST,
|
||||
port: Number(PLATFORM_WS_PORT),
|
||||
clientMessages: CoordinatorToPlatformMessages,
|
||||
serverMessages: PlatformToCoordinatorMessages,
|
||||
authToken: PLATFORM_SECRET,
|
||||
handlers: {
|
||||
RESUME: async (message) => {
|
||||
const taskSocket = await this.#getAttemptSocket(message.attemptId);
|
||||
|
||||
if (!taskSocket) {
|
||||
logger.log("Socket for attempt not found", { attemptId: message.attemptId });
|
||||
return;
|
||||
}
|
||||
|
||||
taskSocket.emit("RESUME", message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return platformConnection;
|
||||
}
|
||||
|
||||
async #getAttemptSocket(attemptId: string) {
|
||||
const sockets = await this.#prodWorkerNamespace.fetchSockets();
|
||||
|
||||
for (const socket of sockets) {
|
||||
if (socket.data.attemptId === attemptId) {
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#createProdWorkerNamespace(io: Server) {
|
||||
const provider = new ZodNamespace({
|
||||
io,
|
||||
name: "prod-worker",
|
||||
clientMessages: ProdWorkerToCoordinatorMessages,
|
||||
serverMessages: CoordinatorToProdWorkerMessages,
|
||||
socketData: ProdWorkerSocketData,
|
||||
postAuth: async (socket, next, logger) => {
|
||||
function setSocketDataFromHeader(dataKey: keyof typeof socket.data, headerName: string) {
|
||||
const value = socket.handshake.headers[headerName];
|
||||
if (!value) {
|
||||
logger(`missing required header: ${headerName}`);
|
||||
throw new Error("missing header");
|
||||
}
|
||||
0;
|
||||
socket.data[dataKey] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
try {
|
||||
setSocketDataFromHeader("podName", "x-pod-name");
|
||||
setSocketDataFromHeader("contentHash", "x-trigger-content-hash");
|
||||
setSocketDataFromHeader("cliPackageVersion", "x-trigger-cli-package-version");
|
||||
setSocketDataFromHeader("projectRef", "x-trigger-project-ref");
|
||||
setSocketDataFromHeader("attemptId", "x-trigger-attempt-id");
|
||||
setSocketDataFromHeader("envId", "x-trigger-env-id");
|
||||
} catch (error) {
|
||||
logger(error);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
logger("success", socket.data);
|
||||
|
||||
next();
|
||||
},
|
||||
onConnection: async (socket, handler, sender) => {
|
||||
const logger = new SimpleLogger(`[task][${socket.id}]`);
|
||||
|
||||
this.#platformSocket?.send("LOG", {
|
||||
metadata: {
|
||||
projectRef: socket.data.projectRef,
|
||||
attemptId: socket.data.attemptId,
|
||||
},
|
||||
text: "connected",
|
||||
});
|
||||
|
||||
socket.on("LOG", (message, callback) => {
|
||||
logger.log("[LOG]", message.text);
|
||||
|
||||
callback();
|
||||
|
||||
this.#platformSocket?.send("LOG", {
|
||||
version: "v1",
|
||||
metadata: { attemptId: socket.data.attemptId },
|
||||
text: message.text,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("READY_FOR_EXECUTION", async (message) => {
|
||||
logger.log("[READY_FOR_EXECUTION]", message);
|
||||
|
||||
const executionAck = await this.#platformSocket?.sendWithAck("READY_FOR_EXECUTION", {
|
||||
version: "v1",
|
||||
attemptId: message.attemptId,
|
||||
});
|
||||
|
||||
if (!executionAck) {
|
||||
logger.error("no execution ack", { attemptId: socket.data.attemptId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!executionAck.success) {
|
||||
logger.error("execution unsuccessful", { attemptId: socket.data.attemptId });
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: shouldn't wait for completion here
|
||||
const completionAck = await socket.emitWithAck("EXECUTE_TASK_RUN", {
|
||||
version: "v1",
|
||||
executionPayload: executionAck.payload,
|
||||
});
|
||||
|
||||
if (!completionAck.success) {
|
||||
logger.error("completion unsuccessful", { attemptId: socket.data.attemptId });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("completed task", { completionId: completionAck.completion.id });
|
||||
|
||||
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
|
||||
version: "v1",
|
||||
execution: executionAck.payload.execution,
|
||||
completion: completionAck.completion,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("WAIT_FOR_DURATION", async (message, callback) => {
|
||||
logger.log("[WAIT_FOR_DURATION]", message);
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush(socket.data.podName);
|
||||
|
||||
if (!checkpoint) {
|
||||
logger.error("Failed to checkpoint", { podName: socket.data.podName });
|
||||
callback({ success: false });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#platformSocket?.send("CHECKPOINT_CREATED", {
|
||||
version: "v1",
|
||||
attemptId: socket.data.attemptId,
|
||||
docker: checkpoint.docker,
|
||||
location: checkpoint.destination,
|
||||
reason: "WAIT_FOR_DURATION",
|
||||
});
|
||||
|
||||
callback({ success: true });
|
||||
});
|
||||
|
||||
socket.on("INDEX_TASKS", async (message, callback) => {
|
||||
logger.log("[INDEX_TASKS]", message);
|
||||
|
||||
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
|
||||
version: "v1",
|
||||
projectRef: socket.data.projectRef,
|
||||
envId: socket.data.envId,
|
||||
metadata: {
|
||||
cliPackageVersion: socket.data.cliPackageVersion,
|
||||
contentHash: socket.data.contentHash,
|
||||
packageVersion: message.packageVersion,
|
||||
tasks: message.tasks,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workerAck) {
|
||||
logger.debug("no worker ack while indexing", message);
|
||||
}
|
||||
|
||||
callback({ success: !!workerAck?.success });
|
||||
});
|
||||
},
|
||||
onDisconnect: async (socket, handler, sender, logger) => {
|
||||
this.#platformSocket?.send("LOG", {
|
||||
metadata: {
|
||||
projectRef: socket.data.projectRef,
|
||||
attemptId: socket.data.attemptId,
|
||||
},
|
||||
text: "disconnect",
|
||||
});
|
||||
},
|
||||
handlers: {
|
||||
TASK_HEARTBEAT: async (message) => {
|
||||
this.#platformSocket?.send("TASK_HEARTBEAT", message);
|
||||
},
|
||||
WAIT_FOR_BATCH: async (message) => {
|
||||
// this.#checkpointer.checkpointAndPush(socket.data.podName);
|
||||
},
|
||||
WAIT_FOR_TASK: async (message) => {
|
||||
// this.#checkpointer.checkpointAndPush(socket.data.podName);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
#createHttpServer() {
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
logger.log(`[${req.method}]`, req.url);
|
||||
|
||||
const reply = new HttpReply(res);
|
||||
|
||||
switch (req.url) {
|
||||
case "/health": {
|
||||
return reply.text("ok");
|
||||
}
|
||||
case "/metrics": {
|
||||
return reply.text(await register.metrics(), 200, register.contentType);
|
||||
}
|
||||
case "/whoami": {
|
||||
return reply.text(NODE_NAME);
|
||||
}
|
||||
case "/checkpoint": {
|
||||
const body = await getTextBody(req);
|
||||
await this.#checkpointer.checkpointAndPush(body);
|
||||
return reply.text(`sent restore request: ${body}`);
|
||||
}
|
||||
default: {
|
||||
return reply.empty(404);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.on("clientError", (err, socket) => {
|
||||
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
||||
});
|
||||
|
||||
httpServer.on("listening", () => {
|
||||
logger.log("server listening on port", HTTP_SERVER_PORT);
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
listen() {
|
||||
this.#httpServer.listen(this.port, this.host);
|
||||
}
|
||||
}
|
||||
|
||||
const coordinator = new TaskCoordinator(HTTP_SERVER_PORT);
|
||||
coordinator.listen();
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@trigger.dev/core/v3": ["../core/src/v3"],
|
||||
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
|
||||
"@trigger.dev/core-apps": ["../core-apps/src"],
|
||||
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
HTTP_SERVER_PORT=8050
|
||||
|
||||
PLATFORM_WS_PORT=3030
|
||||
PLATFORM_SECRET=provider-secret
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
@@ -0,0 +1,16 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18-slim AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y dumb-init
|
||||
|
||||
FROM base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node dist/index.mjs /app/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "/app/index.mjs" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Docker provider
|
||||
|
||||
The `docker-provider` allows the platform to be orchestrator-agnostic. The platform can perform actions such as `INDEX_TASKS` or `INVOKE_TASK` which the provider translates into Docker actions.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "docker-provider",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:bundle",
|
||||
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.mjs --platform=node --format=esm --target=esnext --banner:js=\"const require = createRequire(import.meta.url);\"",
|
||||
"build:image": "docker build -f Containerfile . -t docker-provider",
|
||||
"dev": "tsx --no-warnings=ExperimentalWarning --require dotenv/config --watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-apps": "workspace:*",
|
||||
"execa": "^8.0.1",
|
||||
"socket.io-client": "^4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.8",
|
||||
"dotenv": "^16.4.2",
|
||||
"esbuild": "^0.19.11",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { $ } from "execa";
|
||||
import { Machine } from "@trigger.dev/core/v3";
|
||||
import { SimpleLogger, TaskOperations, ProviderShell } from "@trigger.dev/core-apps";
|
||||
|
||||
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
|
||||
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
|
||||
|
||||
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
|
||||
|
||||
class DockerTaskOperations implements TaskOperations {
|
||||
async index(opts: { contentHash: string; imageTag: string; envId: string }) {
|
||||
const containerName = this.#getIndexContainerName(opts.contentHash);
|
||||
|
||||
const { exitCode } = logger.debug(
|
||||
await $`docker run --rm -e COORDINATOR_PORT=${COORDINATOR_PORT} -e POD_NAME=${containerName} -e TRIGGER_ENV_ID=${opts.envId} -e INDEX_TASKS=true --network=host --pull=never --name=${containerName} ${opts.imageTag}`
|
||||
);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("docker run command failed");
|
||||
}
|
||||
}
|
||||
|
||||
async create(opts: { attemptId: string; image: string; machine: Machine; envId: string }) {
|
||||
const containerName = this.#getRunContainerName(opts.attemptId);
|
||||
|
||||
const { exitCode } = logger.debug(
|
||||
await $`docker run -d -e COORDINATOR_PORT=${COORDINATOR_PORT} -e POD_NAME=${containerName} -e TRIGGER_ENV_ID=${opts.envId} -e TRIGGER_ATTEMPT_ID=${opts.attemptId} --network=host --pull=never --name=${containerName} ${opts.image}`
|
||||
);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("docker run command failed");
|
||||
}
|
||||
}
|
||||
|
||||
async restore(opts: {
|
||||
attemptId: string;
|
||||
runId: string;
|
||||
image: string;
|
||||
name: string;
|
||||
checkpointId: string;
|
||||
machine: Machine;
|
||||
}) {
|
||||
const containerName = this.#getRunContainerName(opts.attemptId);
|
||||
|
||||
const { exitCode } = logger.debug(
|
||||
await $`docker start --checkpoint=${opts.checkpointId} ${containerName}`
|
||||
);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error("docker start command failed");
|
||||
}
|
||||
}
|
||||
|
||||
async delete(opts: { runId: string }) {
|
||||
logger.log("noop: delete");
|
||||
}
|
||||
|
||||
async get(opts: { runId: string }) {
|
||||
logger.log("noop: get");
|
||||
}
|
||||
|
||||
#getIndexContainerName(contentHash: string) {
|
||||
return `task-index-${contentHash}`;
|
||||
}
|
||||
|
||||
#getRunContainerName(attemptId: string) {
|
||||
return `task-run-${attemptId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new DockerTaskOperations(),
|
||||
});
|
||||
|
||||
provider.listen();
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@trigger.dev/core/v3": ["../core/src/v3"],
|
||||
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
|
||||
"@trigger.dev/core-apps": ["../core-apps/src"],
|
||||
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
HTTP_SERVER_PORT=8060
|
||||
|
||||
PLATFORM_WS_PORT=8003
|
||||
PLATFORM_SECRET=provider-secret
|
||||
|
||||
REGISTRY_FQDN=docker.io
|
||||
REPO_NAME=task
|
||||
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
@@ -0,0 +1,48 @@
|
||||
FROM node:18-alpine@sha256:ca9f6cb0466f9638e59e0c249d335a07c867cd50c429b5c7830dda1bed584649 AS node-18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=kubernetes-provider --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18-alpine AS base
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
FROM base AS dev-deps
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV development
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN corepack enable
|
||||
|
||||
COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter '@trigger.dev/core*' build
|
||||
RUN pnpm run -r --filter kubernetes-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=builder --chown=node:node /app/apps/kubernetes-provider/dist/index.mjs ./index.mjs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
USER node
|
||||
|
||||
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.mjs" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
# Kubernetes provider
|
||||
|
||||
The `kubernetes-provider` allows the platform to be orchestrator-agnostic. The platform can perform actions such as `INDEX_TASKS` or `INVOKE_TASK` which the provider translates into Kubernetes actions.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "kubernetes-provider",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:bundle",
|
||||
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.mjs --platform=node --format=esm --target=esnext --banner:js=\"const require = createRequire(import.meta.url);\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^0.20.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/core-apps": "workspace:*",
|
||||
"socket.io-client": "^4.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv": "^16.4.2",
|
||||
"esbuild": "^0.19.11",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import k8s, { BatchV1Api, CoreV1Api, V1Job, V1Pod } from "@kubernetes/client-node";
|
||||
import { Machine } from "@trigger.dev/core/v3";
|
||||
import { ProviderShell, SimpleLogger, TaskOperations } from "@trigger.dev/core-apps";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "some-node";
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318";
|
||||
|
||||
const REGISTRY_FQDN = process.env.REGISTRY_FQDN || "localhost:5000";
|
||||
const REPO_NAME = process.env.REPO_NAME || "test";
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
|
||||
type Namespace = {
|
||||
metadata: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
class KubernetesTaskOperations implements TaskOperations {
|
||||
#namespace: Namespace;
|
||||
#k8sApi: {
|
||||
core: CoreV1Api;
|
||||
batch: BatchV1Api;
|
||||
};
|
||||
|
||||
constructor(namespace = "default") {
|
||||
this.#namespace = {
|
||||
metadata: {
|
||||
name: namespace,
|
||||
},
|
||||
};
|
||||
|
||||
this.#k8sApi = this.#createK8sApi();
|
||||
}
|
||||
|
||||
async index(opts: { contentHash: string; imageTag: string; envId: string }) {
|
||||
await this.#createJob(
|
||||
{
|
||||
metadata: {
|
||||
name: `task-index-${opts.contentHash}`,
|
||||
namespace: this.#namespace.metadata.name,
|
||||
},
|
||||
spec: {
|
||||
completions: 1,
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
app: "task-index",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
imagePullSecrets: [
|
||||
{
|
||||
name: "registry-trigger",
|
||||
},
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: opts.contentHash,
|
||||
image: opts.imageTag,
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
},
|
||||
],
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: "100m",
|
||||
memory: "50Mi",
|
||||
},
|
||||
},
|
||||
env: [
|
||||
{
|
||||
name: "DEBUG",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
name: "INDEX_TASKS",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
value: OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
},
|
||||
{
|
||||
name: "HTTP_SERVER_PORT",
|
||||
value: "8000",
|
||||
},
|
||||
{
|
||||
name: "POD_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "metadata.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "COORDINATOR_HOST",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "status.hostIP",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MACHINE_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "spec.nodeName",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
this.#namespace
|
||||
);
|
||||
}
|
||||
|
||||
async create(opts: { attemptId: string; image: string; machine: Machine; envId: string }) {
|
||||
await this.#createPod(
|
||||
{
|
||||
metadata: {
|
||||
name: `task-run-${opts.attemptId}-${randomUUID().slice(0, 5)}`,
|
||||
namespace: this.#namespace.metadata.name,
|
||||
labels: {
|
||||
app: "task-run",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
imagePullSecrets: [
|
||||
{
|
||||
name: "registry-trigger",
|
||||
},
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: opts.attemptId,
|
||||
image: opts.image,
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
},
|
||||
],
|
||||
// resources: {
|
||||
// limits: opts.machine,
|
||||
// },
|
||||
env: [
|
||||
{
|
||||
name: "DEBUG",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_ATTEMPT_ID",
|
||||
value: opts.attemptId,
|
||||
},
|
||||
{
|
||||
name: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
value: OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
},
|
||||
{
|
||||
name: "POD_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "metadata.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "COORDINATOR_HOST",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "status.hostIP",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NODE_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "spec.nodeName",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
this.#namespace
|
||||
);
|
||||
}
|
||||
|
||||
async restore(opts: {
|
||||
attemptId: string;
|
||||
runId: string;
|
||||
image: string;
|
||||
name: string;
|
||||
checkpointId: string;
|
||||
machine: Machine;
|
||||
}) {
|
||||
await this.#createPod(
|
||||
{
|
||||
metadata: {
|
||||
name: opts.name,
|
||||
namespace: this.#namespace.metadata.name,
|
||||
},
|
||||
spec: {
|
||||
imagePullSecrets: [
|
||||
{
|
||||
name: "regcred",
|
||||
},
|
||||
],
|
||||
initContainers: [
|
||||
{
|
||||
name: "pull-base-image",
|
||||
image: this.#getRestoreImage(opts.runId, opts.checkpointId),
|
||||
command: ["sleep", "0"],
|
||||
},
|
||||
],
|
||||
containers: [
|
||||
{
|
||||
name: opts.runId,
|
||||
image: this.#getImageFromRunId(opts.runId),
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
},
|
||||
],
|
||||
// resources: {
|
||||
// limits: opts.machine,
|
||||
// },
|
||||
lifecycle: {
|
||||
postStart: {
|
||||
httpGet: {
|
||||
path: "/connect",
|
||||
port: 8000,
|
||||
},
|
||||
},
|
||||
},
|
||||
env: [
|
||||
{
|
||||
name: "DEBUG",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
name: "POD_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "metadata.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "COORDINATOR_HOST",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "status.hostIP",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NODE_NAME",
|
||||
valueFrom: {
|
||||
fieldRef: {
|
||||
fieldPath: "spec.nodeName",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
this.#namespace
|
||||
);
|
||||
}
|
||||
|
||||
async delete(opts: { runId: string }) {
|
||||
await this.#deletePod({
|
||||
podName: opts.runId,
|
||||
namespace: this.#namespace,
|
||||
});
|
||||
}
|
||||
|
||||
async get(opts: { runId: string }) {
|
||||
await this.#getPod(opts.runId, this.#namespace);
|
||||
}
|
||||
|
||||
#getImageFromRunId(runId: string) {
|
||||
return `${REGISTRY_FQDN}/${REPO_NAME}:${runId}`;
|
||||
}
|
||||
|
||||
#getRestoreImage(runId: string, checkpointId: string) {
|
||||
return `${REGISTRY_FQDN}/${REPO_NAME}:${checkpointId}`;
|
||||
}
|
||||
|
||||
#createK8sApi() {
|
||||
const kubeConfig = new k8s.KubeConfig();
|
||||
|
||||
if (RUNTIME_ENV === "local") {
|
||||
kubeConfig.loadFromDefault();
|
||||
} else if (RUNTIME_ENV === "kubernetes") {
|
||||
kubeConfig.loadFromCluster();
|
||||
} else {
|
||||
throw new Error(`Unsupported runtime environment: ${RUNTIME_ENV}`);
|
||||
}
|
||||
|
||||
return {
|
||||
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
|
||||
batch: kubeConfig.makeApiClient(k8s.BatchV1Api),
|
||||
};
|
||||
}
|
||||
|
||||
async #createPod(pod: V1Pod, namespace: Namespace) {
|
||||
try {
|
||||
const res = await this.#k8sApi.core.createNamespacedPod(namespace.metadata.name, pod);
|
||||
logger.debug(res.body);
|
||||
} catch (err: any) {
|
||||
if ("body" in err) {
|
||||
logger.error(err.body);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #deletePod(opts: { podName: string; namespace: Namespace }) {
|
||||
try {
|
||||
const res = await this.#k8sApi.core.deleteNamespacedPod(
|
||||
opts.podName,
|
||||
opts.namespace.metadata.name
|
||||
);
|
||||
logger.debug(res.body);
|
||||
} catch (err: any) {
|
||||
if ("body" in err) {
|
||||
logger.error(err.body);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #getPod(podName: string, namespace: Namespace) {
|
||||
try {
|
||||
const res = await this.#k8sApi.core.readNamespacedPod(podName, namespace.metadata.name);
|
||||
logger.debug(res.body);
|
||||
return res.body;
|
||||
} catch (err: any) {
|
||||
if ("body" in err) {
|
||||
logger.error(err.body);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #createJob(job: V1Job, namespace: Namespace) {
|
||||
try {
|
||||
const res = await this.#k8sApi.batch.createNamespacedJob(namespace.metadata.name, job);
|
||||
logger.debug(res.body);
|
||||
} catch (err: any) {
|
||||
if ("body" in err) {
|
||||
logger.error(err.body);
|
||||
} else {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new KubernetesTaskOperations(),
|
||||
});
|
||||
|
||||
provider.listen();
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@trigger.dev/core/v3": ["../core/src/v3"],
|
||||
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
|
||||
"@trigger.dev/core-apps": ["../core-apps/src"],
|
||||
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxy",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
|
||||
@@ -27,7 +27,6 @@ const config: StorybookConfig = {
|
||||
"@storybook/addon-interactions",
|
||||
"storybook-addon-variants",
|
||||
"storybook-addon-designs",
|
||||
"@storybook/addon-docs",
|
||||
{
|
||||
name: "@storybook/addon-styling",
|
||||
options: {
|
||||
|
||||
@@ -19,7 +19,7 @@ const preview: Preview = {
|
||||
values: [
|
||||
{
|
||||
name: "App background",
|
||||
value: "#0B1018",
|
||||
value: "#15171A",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function AttemptIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.0711 0.585818C8.29001 -0.195272 7.02361 -0.195273 6.24252 0.585818L0.585818 6.24252C-0.195272 7.02361 -0.195273 8.29001 0.585818 9.0711L6.24252 14.7278C7.02361 15.5089 8.29001 15.5089 9.0711 14.7278L14.7278 9.0711C15.5089 8.29001 15.5089 7.02361 14.7278 6.24252L9.0711 0.585818ZM6.85833 4.07179L4.29833 11.1718H5.65833L6.24833 9.49179H9.05833L9.64833 11.1718H11.0083L8.44833 4.07179H6.85833ZM8.67833 8.38179H6.62833L7.65833 5.38179L8.67833 8.38179Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ExitIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="3.5" y1="8" x2="11.5" y2="8" stroke="currentColor" stroke-linecap="round" />
|
||||
<line x1="15.5" y1="1.5" x2="15.5" y2="14.5" stroke="currentColor" stroke-linecap="round" />
|
||||
<path
|
||||
d="M8.5 4.5L12 8L8.5 11.5"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function ShowParentIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.125 16H3.625H4.125V15H3.125H2C1.89733 15 1.8 14.9848 1.70919 14.9573L1.19765 14.8024L1.04267 14.2908C1.01515 14.2 1 14.1027 1 14V12.875V11.875H0V12.375V12.875V14C0 14.202 0.0299409 14.397 0.0856265 14.5808C0.135047 14.7439 0.204744 14.8982 0.291786 15.0407C0.363386 15.158 0.446722 15.2673 0.540161 15.3671C0.570065 15.399 0.601004 15.4299 0.632924 15.4598C0.732663 15.5533 0.841982 15.6366 0.95925 15.7082C1.10181 15.7953 1.25612 15.865 1.41923 15.9144C1.60303 15.9701 1.79802 16 2 16H3.125ZM10.125 15V16H9.625H9.125H6.875H6.375H5.875V15H6.875H9.125H10.125ZM15 5.875H16V6.375V6.875V9.125V9.625V10.125H15V9.125V6.875V5.875ZM5.875 1V0H6.375H6.875H9.125H9.625H10.125V1H9.125H6.875H5.875ZM1 10.125H0V9.625V9.125V6.875V6.375V5.875H1V6.875V9.125V10.125ZM0 3.625V4.125H1V3.125V2C1 1.89733 1.01515 1.8 1.04267 1.70919L1.19765 1.19765L1.70919 1.04267C1.8 1.01515 1.89733 1 2 1H3.125H4.125V0H3.625H3.125H2C1.79802 0 1.60303 0.0299409 1.41923 0.0856264C1.25612 0.135047 1.10181 0.204744 0.95925 0.291786C0.841983 0.363386 0.732663 0.446722 0.632924 0.540161C0.601004 0.570065 0.570065 0.601004 0.540161 0.632924C0.446722 0.732663 0.363386 0.841982 0.291786 0.95925C0.204744 1.10181 0.135047 1.25612 0.0856265 1.41923C0.0299409 1.60303 0 1.79802 0 2V3.125V3.625ZM12.375 0H11.875V1H12.875H14C14.1027 1 14.2 1.01515 14.2908 1.04267L14.8024 1.19765L14.9573 1.70919C14.9848 1.8 15 1.89733 15 2V3.125V4.125H16V3.625V3.125V2C16 1.79802 15.9701 1.60303 15.9144 1.41923C15.865 1.25612 15.7953 1.10181 15.7082 0.95925C15.6366 0.841982 15.5533 0.732663 15.4598 0.632924C15.4299 0.601004 15.399 0.570065 15.3671 0.540161C15.2673 0.446722 15.158 0.363386 15.0407 0.291786C14.8982 0.204744 14.7439 0.135047 14.5808 0.0856265C14.397 0.0299409 14.202 0 14 0H12.875H12.375ZM16 12.375V11.875H15V12.875V14C15 14.1027 14.9848 14.2 14.9573 14.2908L14.8024 14.8024L14.2908 14.9573C14.2 14.9848 14.1027 15 14 15H12.875H11.875V16H12.375H12.875H14C14.202 16 14.397 15.9701 14.5808 15.9144C14.7439 15.865 14.8982 15.7953 15.0407 15.7082C15.158 15.6366 15.2673 15.5533 15.3671 15.4598C15.399 15.4299 15.4299 15.399 15.4598 15.3671C15.5533 15.2673 15.6366 15.158 15.7082 15.0407C15.7953 14.8982 15.865 14.7439 15.9144 14.5808C15.9701 14.397 16 14.202 16 14V12.875V12.375ZM8.75 6.26758L10.4523 7.96991C10.7452 8.26281 11.2201 8.26281 11.513 7.96991C11.8059 7.67702 11.8059 7.20215 11.513 6.90925L8.59099 3.98725C8.43431 3.83057 8.22556 3.7577 8.02045 3.76865C7.81604 3.75834 7.60821 3.83124 7.45209 3.98736L4.53009 6.90937C4.23719 7.20226 4.23719 7.67713 4.53009 7.97003C4.82298 8.26292 5.29785 8.26292 5.59075 7.97003L7.25 6.31077L7.25 11.25C7.25 11.6642 7.58579 12 8 12C8.41421 12 8.75 11.6642 8.75 11.25L8.75 6.26758Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowParentIconSelected({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 0C0.895431 0 0 0.895431 0 2V14C0 15.1046 0.895431 16 2 16H14C15.1046 16 16 15.1046 16 14V2C16 0.895431 15.1046 0 14 0H2ZM8.75 6.26758L10.4523 7.96991C10.7452 8.26281 11.2201 8.26281 11.513 7.96991C11.8059 7.67702 11.8059 7.20215 11.513 6.90925L8.59099 3.98725C8.43344 3.8297 8.22324 3.7569 8.01703 3.76884C7.8138 3.75958 7.60752 3.83254 7.45234 3.98773L4.53033 6.90973C4.23744 7.20263 4.23744 7.6775 4.53033 7.97039C4.82322 8.26329 5.2981 8.26329 5.59099 7.97039L7.25 6.31138L7.25 11.25C7.25 11.6642 7.58579 12 8 12C8.41421 12 8.75 11.6642 8.75 11.25L8.75 6.26758Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function TaskIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.0711 0.585818C8.29001 -0.195272 7.02361 -0.195273 6.24252 0.585818L0.585818 6.24252C-0.195272 7.02361 -0.195273 8.29001 0.585818 9.0711L6.24252 14.7278C7.02361 15.5089 8.29001 15.5089 9.0711 14.7278L14.7278 9.0711C15.5089 8.29001 15.5089 7.02361 14.7278 6.24252L9.0711 0.585818ZM6.85833 4.07179L4.29833 11.1718H5.65833L6.24833 9.49179H9.05833L9.64833 11.1718H11.0083L8.44833 4.07179H6.85833ZM8.67833 8.38179H6.62833L7.65833 5.38179L8.67833 8.38179Z"
|
||||
/>
|
||||
|
||||
<g clipPath="url(#clip0_8651_301570)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16 0H0V16H16V0ZM10.8099 6.05039V4.90039H5.18994V6.05039H7.33994V12.0004H8.65994V6.05039H10.8099Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_8651_301570">
|
||||
<path
|
||||
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z"
|
||||
fill="white"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
Binary file not shown.
|
Before Width: | Height: | Size: 151 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 B |
@@ -17,13 +17,13 @@ export function ActiveBadge({ active, className, badgeSize = "normal" }: ActiveB
|
||||
switch (active) {
|
||||
case true:
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-slate-800 text-green-500", className)}>
|
||||
<span className={cn(variant[badgeSize], "bg-charcoal-800 text-green-500", className)}>
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
case false:
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-slate-800 text-dimmed", className)}>
|
||||
<span className={cn(variant[badgeSize], "bg-charcoal-800 text-text-dimmed", className)}>
|
||||
Disabled
|
||||
</span>
|
||||
);
|
||||
@@ -52,6 +52,6 @@ export function NewBadge({
|
||||
badgeSize?: keyof typeof variant;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn(variant[badgeSize], "bg-green-600 text-background", className)}>New!</span>
|
||||
<span className={cn(variant[badgeSize], "text-background bg-green-600", className)}>New!</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ type ComingSoonProps = {
|
||||
export function ComingSoon({ title, description, icon }: ComingSoonProps) {
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col justify-center rounded border border-slate-800 bg-slate-850">
|
||||
<div className="flex items-center gap-2 border-b border-slate-750 px-4 py-4">
|
||||
<div className="flex flex-col justify-center rounded border border-charcoal-800 bg-charcoal-850">
|
||||
<div className="flex items-center gap-2 border-b border-charcoal-750 px-4 py-4">
|
||||
{typeof icon === "string" ? (
|
||||
<NamedIconInBox name={icon} className={"h-10 w-10 bg-midnight-800"} />
|
||||
<NamedIconInBox name={icon} className={"bg-midnight-800 h-10 w-10"} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
|
||||
@@ -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-charcoal-600 decoration-dashed underline-offset-4 transition hover:decoration-charcoal-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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type DisplayOptionsProps = {
|
||||
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header1 className="mb-4 border-b border-slate-800 pb-4">{title}</Header1>
|
||||
<Header1 className="mb-4 border-b border-charcoal-800 pb-4">{title}</Header1>
|
||||
{message && <Header3>{message}</Header3>}
|
||||
<LinkButton to={button ? button.to : "/"} variant="primary/medium" className="mt-8">
|
||||
{button ? button.title : "Home"}
|
||||
|
||||
@@ -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,24 +112,22 @@ 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"
|
||||
className="group mb-4 flex w-full items-center justify-between rounded-md border border-charcoal-600 bg-gradient-to-br from-blue-400/30 to-indigo-400/50 p-4 transition hover:border-indigo-400"
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<DiscordIcon className="h-8 w-8" />
|
||||
<h2 className="font-title text-2xl text-bright transition group-hover:text-white">
|
||||
<h2 className="font-title text-2xl text-text-bright transition group-hover:text-white">
|
||||
Join the Trigger.dev
|
||||
<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-charcoal-400 transition group-hover:translate-x-1 group-hover:text-indigo-400" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-15
@@ -1,16 +1,16 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Direction, RunList } from "~/presenters/RunListPresenter.server";
|
||||
import { WebhookDeliveryList } from "~/presenters/WebhookDeliveryListPresenter.server";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function ListPagination({
|
||||
list,
|
||||
className,
|
||||
}: {
|
||||
list: RunList | WebhookDeliveryList;
|
||||
className?: string;
|
||||
}) {
|
||||
type List = {
|
||||
pagination: {
|
||||
next?: string | undefined;
|
||||
previous?: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export function ListPagination({ list, className }: { list: List; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
<PreviousButton cursor={list.pagination.previous} />
|
||||
@@ -25,11 +25,13 @@ function NextButton({ cursor }: { cursor?: string }) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"tertiary/small"}
|
||||
TrailingIcon="chevron-right"
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon="arrow-right"
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
|
||||
!path &&
|
||||
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
>
|
||||
@@ -44,11 +46,13 @@ function PreviousButton({ cursor }: { cursor?: string }) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon="chevron-left"
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon="arrow-left"
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
!path && "cursor-default opacity-50 group-hover:bg-transparent group-hover:text-slate-800"
|
||||
!path &&
|
||||
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
>
|
||||
@@ -43,7 +43,7 @@ const quotes: QuoteType[] = [
|
||||
];
|
||||
|
||||
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 gridCell = "hover:bg-background-bright rounded-lg transition bg-background-bright/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";
|
||||
@@ -61,7 +61,7 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
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="border-midnight-750 z-10 col-span-12 border-r bg-background-bright 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">
|
||||
@@ -106,12 +106,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
<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">
|
||||
<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-charcoal-600 before:opacity-20 before:content-['❝'] group-hover:text-charcoal-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"
|
||||
className="mt-4 text-gray-700 transition group-hover:text-charcoal-600"
|
||||
>
|
||||
{randomQuote?.person}
|
||||
</Paragraph>
|
||||
|
||||
@@ -7,9 +7,9 @@ export function NoMobileOverlay() {
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
|
||||
<div className="relative h-full w-full bg-black opacity-90"></div>
|
||||
<div className="absolute mx-8 flex flex-col items-center gap-6 rounded-lg bg-slate-850 px-8 py-10 text-center">
|
||||
<div className="absolute mx-8 flex flex-col items-center gap-6 rounded-lg bg-charcoal-850 px-8 py-10 text-center">
|
||||
<XMarkIcon className="absolute top-14 h-8 w-8 text-rose-600" />
|
||||
<DevicePhoneMobileIcon className="h-16 w-16 text-slate-500" />
|
||||
<DevicePhoneMobileIcon className="h-16 w-16 text-charcoal-500" />
|
||||
<Paragraph>Trigger.dev is currently only available on desktop.</Paragraph>
|
||||
<LinkButton to="https://trigger.dev" variant="primary/medium">
|
||||
Back Home
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export function ProductHuntBanner() {
|
||||
<LinkButton
|
||||
to="https://www.producthunt.com/posts/trigger-dev"
|
||||
target="_blank"
|
||||
className="!text-white underline underline-offset-2 transition hover:decoration-slate-100 hover:decoration-2"
|
||||
className="!text-white underline underline-offset-2 transition hover:decoration-charcoal-100 hover:decoration-2"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Vote for us today only!
|
||||
|
||||
@@ -25,6 +25,6 @@ export function UserAvatar({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<UserCircleIcon className={cn("aspect-square text-slate-400", className)} />
|
||||
<UserCircleIcon className={cn("aspect-square text-charcoal-400", className)} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { environmentTextClassName, environmentTitle } from "./environments/EnvironmentLabel";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
|
||||
type VersionLabelProps = {
|
||||
environment: Environment;
|
||||
userName?: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export function VersionLabel({ environment, userName, version }: VersionLabelProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-midnight-700 inline-flex items-center justify-stretch justify-items-stretch rounded-sm border text-xxs"
|
||||
)}
|
||||
>
|
||||
<div className="px-1 text-xs tabular-nums text-text-dimmed">v{version}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border-midnight-700 inline-flex items-center justify-center rounded-r-sm border-l px-1 text-xxs font-medium uppercase tracking-wider",
|
||||
environmentTextClassName(environment)
|
||||
)}
|
||||
>
|
||||
{environmentTitle(environment, userName)}
|
||||
</div>
|
||||
</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-charcoal-700 bg-charcoal-750 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-text-dimmed" />
|
||||
<Paragraph className="text-2sm text-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,257 @@
|
||||
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-grid-bright">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-primary" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
className="block h-5 w-5 rounded-full border-4 border-primary bg-charcoal-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-primary hover:bg-charcoal-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" key={i}>
|
||||
{tier.upto}
|
||||
</Paragraph>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full items-start">
|
||||
<span className="ml-6 text-text-dimmed">=</span>
|
||||
<Header2 className="min-w-[8ch] text-right text-text-dimmed">
|
||||
{formatCurrency(cost, true)}
|
||||
</Header2>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6 border-grid-bright" />
|
||||
</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-grid-bright">
|
||||
<Slider.Range className="absolute h-full rounded-full bg-primary" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
className="block h-5 w-5 rounded-full border-4 border-primary bg-charcoal-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-primary hover:bg-charcoal-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-text-dimmed">=</span>
|
||||
<Header2 className="min-w-[8ch] text-right text-text-dimmed">
|
||||
{formatCurrency(cost, true)}
|
||||
</Header2>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="mt-6 border-grid-bright" />
|
||||
</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">{text}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
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="tertiary/large"
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
to={buttonPath}
|
||||
>
|
||||
{actionText}
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary/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-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-primary" : "border-grid-bright"
|
||||
)}
|
||||
>
|
||||
{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-primary" : "text-text-dimmed")}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{flatCost === 0 || flatCost ? (
|
||||
<h3 className="text-4xl font-medium text-text-bright">
|
||||
${flatCost}
|
||||
<span className="text-2sm font-normal tracking-wide text-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-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-primary" />
|
||||
) : (
|
||||
<XMarkIcon className="h-4 w-4 text-charcoal-500" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"font-sans text-sm font-normal",
|
||||
checked ? "text-text-bright" : "text-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-grid-bright 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,61 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
type UpgradePromptProps = {
|
||||
runsEnabled: boolean;
|
||||
runCountCap: number;
|
||||
planPath: string;
|
||||
};
|
||||
|
||||
export function UpgradePrompt({ runsEnabled, runCountCap, planPath }: UpgradePromptProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center justify-between border border-error bg-repeat py-0 pl-3 pr-2"
|
||||
style={{ backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={ExclamationCircleIcon} className="h-5 w-5 text-error" />
|
||||
<Paragraph variant="small" className="text-error">
|
||||
{runsEnabled
|
||||
? `You have exceeded the monthly ${formatNumberCompact(runCountCap)} runs
|
||||
limit`
|
||||
: `No runs are executing because you have exceeded the free limit`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={planPath}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShowUpgradePrompt(organization?: MatchedOrganization) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const shouldShow =
|
||||
organization !== undefined &&
|
||||
currentPlan !== undefined &&
|
||||
currentPlan.usage.exceededRunCount &&
|
||||
currentPlan.usage.runCountCap !== undefined;
|
||||
|
||||
if (!shouldShow) {
|
||||
return { shouldShow };
|
||||
}
|
||||
|
||||
return {
|
||||
shouldShow,
|
||||
runCountCap: currentPlan.usage.runCountCap!,
|
||||
runsEnabled: organization.runsEnabled,
|
||||
};
|
||||
}
|
||||
@@ -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-charcoal-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-charcoal-400",
|
||||
positions[position],
|
||||
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-dimmed px-1.5 text-xs text-text-dimmed">
|
||||
{text}
|
||||
<span className="ml-1 text-text-bright">{value}</span>
|
||||
</Paragraph>
|
||||
}
|
||||
variant="dark"
|
||||
side="top"
|
||||
content={tooltipContent}
|
||||
className="z-50 h-fit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ const extraLinesWhenClipping = 0.35;
|
||||
const defaultTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: "#9CDCFE",
|
||||
backgroundColor: "#0e1521",
|
||||
backgroundColor: "#1A1B1F",
|
||||
},
|
||||
styles: [
|
||||
{
|
||||
@@ -196,7 +196,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-md border border-slate-800", className)}
|
||||
className={cn("relative overflow-hidden rounded-md border border-charcoal-800", className)}
|
||||
style={{
|
||||
backgroundColor: theme.plain.backgroundColor,
|
||||
}}
|
||||
@@ -215,7 +215,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
className={cn(
|
||||
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
|
||||
showChrome ? "top-10" : "top-3",
|
||||
copied ? "text-emerald-500" : "text-slate-500 hover:text-slate-300"
|
||||
copied ? "text-emerald-500" : "text-charcoal-500 hover:text-charcoal-300"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -242,7 +242,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
}) => (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
@@ -291,7 +291,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={
|
||||
"mr-2 flex-none select-none text-right text-slate-500 transition-opacity duration-500"
|
||||
"mr-2 flex-none select-none text-right text-charcoal-500 transition-opacity duration-500"
|
||||
}
|
||||
style={{
|
||||
width: `calc(8 * ${maxLineWidth / 16}rem)`,
|
||||
@@ -328,7 +328,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
@@ -347,17 +347,17 @@ CodeBlock.displayName = "CodeBlock";
|
||||
|
||||
function Chrome({ title }: { title?: string }) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-slate-800 bg-slate-900">
|
||||
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-charcoal-800 bg-charcoal-900">
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-slate-700" />
|
||||
<div className="h-3 w-3 rounded-full bg-slate-700" />
|
||||
<div className="h-3 w-3 rounded-full bg-slate-700" />
|
||||
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
|
||||
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
|
||||
<div className="h-3 w-3 rounded-full bg-charcoal-700" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-sm px-3 py-0.5 text-xs text-slate-500",
|
||||
title && "bg-midnight-900"
|
||||
"rounded-sm px-3 py-0.5 text-xs text-charcoal-500",
|
||||
title && "bg-background-dimmed"
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const inlineCode =
|
||||
"px-1 py-0.5 rounded border border-slate-800 bg-midnight-850 text-sky-200 font-mono";
|
||||
"px-1 py-0.5 rounded border border-charcoal-700 bg-charcoal-800 text-charcoal-200 font-mono";
|
||||
|
||||
const variants = {
|
||||
"extra-extra-small": "text-xxs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json as jsonLang } from "@codemirror/lang-json";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
@@ -108,20 +108,13 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
}, [view]);
|
||||
|
||||
return (
|
||||
<div className={cn(opts.className, "relative")}>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
<div className={cn(opts.className, "grid grid-rows-[2.5rem_1fr]")}>
|
||||
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -134,9 +127,9 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
leadingIconClassName={copied ? "text-green-500 group-hover:text-green-500" : undefined}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={copied ? "text-green-500 group-hover:text-green-500" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -147,6 +140,14 @@ export function JSONEditor(opts: JSONEditorProps) {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="w-full overflow-auto"
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { HighlightStyle } from "@codemirror/language";
|
||||
import { tagHighlighter, tags } from "@lezer/highlight";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
export function darkTheme(): Extension {
|
||||
const chalky = "#e5c07b",
|
||||
@@ -17,15 +16,15 @@ export function darkTheme(): Extension {
|
||||
violet = "#c678dd",
|
||||
darkBackground = "#21252b",
|
||||
highlightBackground = "rgba(71,85,105,0.2)",
|
||||
background = "rgba(11, 16, 24 ,100)",
|
||||
background = "#121317",
|
||||
tooltipBackground = "#353a42",
|
||||
selection = "rgb(71 85 105)",
|
||||
cursor = "#528bff",
|
||||
scrollbarTrack = "#0E1521",
|
||||
scrollbarTrack = "rgba(0,0,0,0)",
|
||||
scrollbarTrackActive = "#131B2B",
|
||||
scrollbarThumb = "#293649",
|
||||
scrollbarThumbActive = "#3C4B62",
|
||||
scrollbarBg = "#0E1521";
|
||||
scrollbarBg = "rgba(0,0,0,0)";
|
||||
|
||||
const jsonHeroEditorTheme = EditorView.theme(
|
||||
{
|
||||
@@ -36,7 +35,7 @@ export function darkTheme(): Extension {
|
||||
|
||||
".cm-content": {
|
||||
caretColor: cursor,
|
||||
fontFamily: "monospace",
|
||||
fontFamily: "Geist Mono Variable",
|
||||
fontSize: "14px",
|
||||
},
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function endpointIndexStatusTitle(status: EndpointIndexStatus): string {
|
||||
export function endpointIndexStatusClassNameColor(status: EndpointIndexStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-dimmed";
|
||||
return "text-text-dimmed";
|
||||
case "STARTED":
|
||||
return "text-blue-500";
|
||||
case "SUCCESS":
|
||||
|
||||
@@ -2,35 +2,45 @@ import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
const variants = {
|
||||
small: "h-4 text-xxs px-[0.1875rem] rounded-[2px]",
|
||||
large: "h-6 text-xs px-1.5 rounded",
|
||||
};
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
userName,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
size?: keyof typeof variants;
|
||||
userName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
|
||||
environmentColorClassName(environment),
|
||||
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName(environment),
|
||||
environmentTextClassName(environment),
|
||||
variants[size],
|
||||
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: You";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
@@ -48,3 +58,29 @@ export function environmentColorClassName(environment: Environment) {
|
||||
return "bg-yellow-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentBorderClassName(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "border-green-500/50";
|
||||
case "STAGING":
|
||||
return "border-amber-500/50";
|
||||
case "DEVELOPMENT":
|
||||
return "border-pink-500/50";
|
||||
case "PREVIEW":
|
||||
return "border-yellow-500/50";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTextClassName(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "text-green-500";
|
||||
case "STAGING":
|
||||
return "text-amber-500";
|
||||
case "DEVELOPMENT":
|
||||
return "text-pink-500";
|
||||
case "PREVIEW":
|
||||
return "text-yellow-500";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
@@ -9,6 +9,10 @@ import { Header1 } from "../primitives/Headers";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
@@ -27,8 +31,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
leadingIconClassName="text-dimmed"
|
||||
variant="minimal/small"
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
>
|
||||
Regenerate
|
||||
@@ -58,47 +62,47 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-5 pb-4">
|
||||
<div className="mt-3 flex gap-x-3 rounded-md border border-ui-border py-4 pl-3 pr-5">
|
||||
<ExclamationTriangleIcon className="relative top-1 h-6 w-6 min-w-[2rem] text-amber-500" />
|
||||
<Paragraph>
|
||||
Regenerating the keys for this environment will temporarily break any live Jobs in the
|
||||
<span className="text-bright"> {title} Environment</span> until the new API keys are set
|
||||
in the relevant environment variables.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-y-5 py-4">
|
||||
<Callout variant="warning">
|
||||
{`Regenerating the keys for this environment will temporarily break any live Jobs in the
|
||||
${title} Environmentuntil the new API keys are set in the relevant environment variables.`}
|
||||
</Callout>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${id}/regenerate-api-key`}
|
||||
className="mt-2 flex w-full flex-col items-center gap-2"
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-x-2">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-ui-border bg-slate-900 px-2 py-1 font-mono text-bright"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
className="rounded-r-none"
|
||||
variant="large"
|
||||
<Fieldset className="w-full">
|
||||
<InputGroup>
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-grid-bright bg-charcoal-900 px-2 py-1 font-mono"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={isSubmitting ? Spinner : undefined}
|
||||
disabled={confirmationText !== randomWord}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
cancelButton={<Button variant={"tertiary/small"}>Cancel</Button>}
|
||||
/>
|
||||
<Button
|
||||
variant="primary/large"
|
||||
disabled={confirmationText !== randomWord}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
{isSubmitting ? <Spinner color="white" /> : <>Regenerate</>}
|
||||
</Button>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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="minimal/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-charcoal-400" />
|
||||
) : (
|
||||
<StopIcon className="h-4 w-4 text-charcoal-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-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-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>
|
||||
);
|
||||
}
|
||||
@@ -36,18 +36,18 @@ export function FrameworkComingSoon({
|
||||
Choose a different framework
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph spacing className="border-b border-slate-800 pb-4">
|
||||
<Paragraph spacing className="border-b border-charcoal-800 pb-4">
|
||||
We're working hard to bring support for {frameworkName} in Trigger.dev. Follow along with
|
||||
the GitHub issue or contribute and help us bring it to Trigger.dev faster.
|
||||
</Paragraph>
|
||||
<a
|
||||
href={githubIssueUrl}
|
||||
target="_blank"
|
||||
className="group mt-4 block max-w-sm rounded-md bg-bright px-10 py-8 transition hover:bg-slate-100"
|
||||
className="bg-bright group mt-4 block max-w-sm rounded-md px-10 py-8 transition hover:bg-charcoal-100"
|
||||
>
|
||||
<Paragraph spacing>triggerdotdev/trigger.dev</Paragraph>
|
||||
<h2 className="text-2xl font-semibold text-black">
|
||||
<span className="mr-1 font-normal text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
<span className="mr-1 font-normal text-text-dimmed">#{githubIssueNumber}</span>Framework:
|
||||
support for {frameworkName}
|
||||
</h2>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
@@ -55,7 +55,7 @@ export function FrameworkComingSoon({
|
||||
<Paragraph variant="small">View on GitHub</Paragraph>
|
||||
<NamedIcon
|
||||
name="arrow-right"
|
||||
className="h-4 w-4 text-dimmed transition group-hover:translate-x-1"
|
||||
className="h-4 w-4 text-text-dimmed transition group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -89,7 +89,7 @@ function FrameworkLink({ children, to, supported = false }: FrameworkLinkProps)
|
||||
<Link
|
||||
to={to}
|
||||
className={cn(
|
||||
"flex h-28 w-full items-center justify-center rounded-md border border-slate-750 px-8 py-4 transition hover:bg-slate-850",
|
||||
"flex h-28 w-full items-center justify-center rounded-md border border-charcoal-750 px-8 py-4 transition hover:bg-charcoal-850",
|
||||
!supported && "border opacity-30 grayscale transition hover:opacity-100 hover:grayscale-0"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import selectExample from "./select-example.png";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { TriggerDevCommand } from "../SetupCommands";
|
||||
import { IntegrationIcon } from "~/assets/icons/IntegrationIcon";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function HowToRunYourJob() {
|
||||
const organization = useOrganization();
|
||||
@@ -41,7 +42,7 @@ export function HowToRunYourJob() {
|
||||
to={jobTestPath(organization, project, job)}
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={"beaker"}
|
||||
leadingIconClassName="text-bright"
|
||||
leadingIconClassName="text-text-bright"
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
@@ -55,9 +56,9 @@ export function HowToRunYourJob() {
|
||||
|
||||
<LinkButton
|
||||
to="https://trigger.dev/docs/documentation/guides/running-jobs"
|
||||
variant={"primary/small"}
|
||||
LeadingIcon={"docs"}
|
||||
leadingIconClassName="text-bright"
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
>
|
||||
How to run a Job
|
||||
</LinkButton>
|
||||
|
||||
@@ -50,16 +50,16 @@ export function ConnectToIntegrationSheet({
|
||||
</div>
|
||||
<LinkButton
|
||||
to={apiReferencePath(integration.identifier)}
|
||||
variant="secondary/small"
|
||||
variant="tertiary/small"
|
||||
TrailingIcon="arrow-up-right"
|
||||
trailingIconClassName="h-4 w-4 text-slate-400"
|
||||
trailingIconClassName="h-4 w-4 text-charcoal-400"
|
||||
target="_blank"
|
||||
>
|
||||
View examples
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsIntegrationPath(integration.identifier)}
|
||||
variant="secondary/small"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon="docs"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
@@ -178,7 +179,7 @@ export function ConnectToOAuthForm({
|
||||
</fieldset> */}
|
||||
<div className="mb-2 mt-4 flex items-center justify-between">
|
||||
<Header3>Select {integration.name} scopes</Header3>
|
||||
<Paragraph variant="small" className="text-slate-500">
|
||||
<Paragraph variant="small" className="text-charcoal-500">
|
||||
{simplur`${selectedScopes.size} scope[|s] selected`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
@@ -231,7 +232,7 @@ export function ConnectToOAuthForm({
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
<div className="absolute bottom-0 left-0 flex w-full items-center justify-end gap-x-4 rounded-b-md border-t border-slate-800 bg-midnight-900 p-4">
|
||||
<div className="absolute bottom-0 left-0 flex w-full items-center justify-end gap-x-4 rounded-b-md border-t border-charcoal-800 bg-background-dimmed p-4">
|
||||
<FormError>{scopes.error}</FormError>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -240,7 +241,7 @@ export function ConnectToOAuthForm({
|
||||
variant="primary/medium"
|
||||
LeadingIcon={integration.icon ?? integration.identifier}
|
||||
>
|
||||
Connect to {integration.name}
|
||||
{`Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
|
||||
@@ -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{" "}
|
||||
@@ -68,14 +41,14 @@ export function CustomHelp({ api }: { api: Api }) {
|
||||
modified to suit your use-case.
|
||||
</Paragraph>
|
||||
{api.examples.length > 1 && (
|
||||
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className=" flex w-full flex-row gap-4 overflow-x-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{api.examples?.map((example, index) => (
|
||||
<button
|
||||
onClick={() => changeCodeExample(index)}
|
||||
key={example.codeUrl}
|
||||
className={cn(
|
||||
"w-64 min-w-[16rem] p-2 transition-colors duration-300 sm:w-full sm:rounded",
|
||||
"border-px focus:border-px cursor-pointer border border-slate-900 bg-slate-900 text-slate-300 transition duration-300 hover:bg-slate-800 focus:border focus:border-indigo-600"
|
||||
"border-px focus:border-px cursor-pointer border border-charcoal-900 bg-charcoal-900 text-charcoal-300 transition duration-300 hover:bg-charcoal-800 focus:border focus:border-indigo-600"
|
||||
)}
|
||||
>
|
||||
{example.title}
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function IntegrationWithMissingFieldSheet({
|
||||
</div>
|
||||
<LinkButton
|
||||
to={docsIntegrationPath(integration.identifier)}
|
||||
variant="secondary/small"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon="docs"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Client } from "~/presenters/IntegrationsPresenter.server";
|
||||
import { UpdateOAuthForm } from "./UpdateOAuthForm";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SelectOAuthMethod({
|
||||
integration,
|
||||
@@ -114,8 +115,8 @@ export function SelectOAuthMethod({
|
||||
We support external authentication providers through Auth Resolvers. Read the docs to
|
||||
learn more:{" "}
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
LeadingIcon={"docs"}
|
||||
variant={"tertiary/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
TrailingIcon={"external-link"}
|
||||
to="https://trigger.dev/docs/documentation/guides/using-integrations-byo-auth"
|
||||
target="_blank"
|
||||
|
||||
@@ -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}
|
||||
@@ -168,7 +168,7 @@ export function UpdateOAuthForm({
|
||||
</fieldset> */}
|
||||
<div className="mb-2 mt-4 flex items-center justify-between">
|
||||
<Header3>Select {integration.name} scopes</Header3>
|
||||
<Paragraph variant="small" className="text-slate-500">
|
||||
<Paragraph variant="small" className="text-charcoal-500">
|
||||
{simplur`${selectedScopes.size} scope[|s] selected`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
@@ -221,7 +221,7 @@ export function UpdateOAuthForm({
|
||||
)}
|
||||
</Fieldset>
|
||||
|
||||
<div className="absolute bottom-0 left-0 flex w-full items-center justify-end gap-x-4 rounded-b-md border-t border-slate-800 bg-midnight-900 p-4">
|
||||
<div className="absolute bottom-0 left-0 flex w-full items-center justify-end gap-x-4 rounded-b-md border-t border-charcoal-800 bg-background-dimmed p-4">
|
||||
<FormError>{scopes.error}</FormError>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -230,7 +230,7 @@ export function UpdateOAuthForm({
|
||||
variant="primary/medium"
|
||||
LeadingIcon={integration.identifier}
|
||||
>
|
||||
Connect to {integration.name}
|
||||
{`Connect to ${integration.name}`}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
|
||||
@@ -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;
|
||||
@@ -49,7 +74,7 @@ export function DeleteJobDialogContent({
|
||||
<Header2
|
||||
className={cn(
|
||||
canDelete ? "border-rose-500 bg-rose-500/10" : "border-amber-500 bg-amber-500/10",
|
||||
"rounded border px-3.5 py-2 text-center text-bright"
|
||||
"rounded border px-3.5 py-2 text-center text-text-bright"
|
||||
)}
|
||||
>
|
||||
{canDelete
|
||||
@@ -59,8 +84,9 @@ export function DeleteJobDialogContent({
|
||||
<Paragraph variant="small" className="px-6 text-center">
|
||||
{canDelete ? (
|
||||
<>
|
||||
This will permanently delete the Job <span className="strong text-bright">{title}</span>
|
||||
. This includes the deletion of all Run history. This cannot be undone.
|
||||
This will permanently delete the Job{" "}
|
||||
<span className="strong text-text-bright">{title}</span>. This includes the deletion of
|
||||
all Run history. This cannot be undone.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -86,7 +112,7 @@ export function DeleteJobDialogContent({
|
||||
<>
|
||||
<NamedIcon
|
||||
name="trash-can"
|
||||
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
|
||||
className="mr-1.5 h-4 w-4 text-text-bright transition group-hover:text-text-bright"
|
||||
/>
|
||||
Delete this Job
|
||||
</>
|
||||
@@ -98,7 +124,7 @@ export function DeleteJobDialogContent({
|
||||
<>
|
||||
<NamedIcon
|
||||
name="trash-can"
|
||||
className="mr-1.5 h-4 w-4 text-bright transition group-hover:text-bright"
|
||||
className="mr-1.5 h-4 w-4 text-text-bright transition group-hover:text-text-bright"
|
||||
/>
|
||||
Delete this Job
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export function JobSkeleton() {
|
||||
return (
|
||||
<div className={"flex w-full gap-x-4 bg-midnight-850 p-4 pr-5 "}>
|
||||
<div className="aspect-square h-6 w-6 rounded border border-slate-800 bg-slate-900 p-1.5 md:h-10 md:w-10 lg:h-12 lg:w-12" />
|
||||
<div className={"flex w-full gap-x-4 bg-background-bright p-4 pr-5 "}>
|
||||
<div className="aspect-square h-6 w-6 rounded border border-charcoal-800 bg-charcoal-900 p-1.5 md:h-10 md:w-10 lg:h-12 lg:w-12" />
|
||||
<div className="flex w-full">
|
||||
<div className="flex w-full flex-col justify-between gap-y-1">
|
||||
<div className="flex items-baseline justify-between gap-x-3 pr-3 md:justify-start md:pr-0">
|
||||
<div className="h-5 w-1/4 rounded border border-slate-800 bg-slate-900" />
|
||||
<div className="h-5 w-1/4 rounded border border-charcoal-800 bg-charcoal-900" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div className="h-2.5 w-1/2 rounded border border-slate-800 bg-slate-900" />
|
||||
<div className="h-2.5 w-1/2 rounded border border-charcoal-800 bg-charcoal-900" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<div className="h-1 w-1/5 rounded border border-slate-800 bg-slate-900" />
|
||||
<div className="h-1 w-1/5 rounded border border-charcoal-800 bg-charcoal-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,54 +1,28 @@
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import gradientPath from "./app-container-gradient.svg";
|
||||
import { useShowUpgradePrompt } from "../billing/UpgradePrompt";
|
||||
|
||||
/** 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 AppContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid h-full w-full grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
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 MainBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
export function PageContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
const organization = useOptionalOrganization();
|
||||
const showUpgradePrompt = useShowUpgradePrompt(organization);
|
||||
|
||||
return (
|
||||
<div className={cn("grid h-full grid-rows-[auto_1fr] overflow-hidden", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-hidden",
|
||||
showUpgradePrompt.shouldShow ? "grid-rows-[5rem_1fr]" : "grid-rows-[2.5rem_1fr]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -57,16 +31,19 @@ export function PageContainer({
|
||||
export function PageBody({
|
||||
children,
|
||||
scrollable = true,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
: "overflow-hidden"
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
: "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -74,10 +51,6 @@ export function PageBody({
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBodyPadding({ children }: { children: React.ReactNode }) {
|
||||
return <div className="p-4">{children}</div>;
|
||||
}
|
||||
|
||||
export function MainCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
@@ -86,7 +59,7 @@ export function MainCenteredContainer({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className={cn("mx-auto mt-[25vh] max-w-xs overflow-y-auto", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
const { v3Enabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn("flex items-center justify-between border-b p-px transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/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-charcoal-600">
|
||||
<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-grid-bright 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,35 +0,0 @@
|
||||
import { UIMatch, useMatches } from "@remix-run/react";
|
||||
import { Fragment, ReactNode } from "react";
|
||||
import { BreadcrumbIcon } from "../primitives/BreadcrumbIcon";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
|
||||
export type BreadcrumbItem = (match: UIMatch, allMatches: UIMatch[]) => ReactNode;
|
||||
|
||||
export function Breadcrumb() {
|
||||
const matches = useMatches() as UIMatch<unknown, Handle>[];
|
||||
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
{matches
|
||||
.filter((b) => b.handle && b.handle.breadcrumb)
|
||||
.map((match, index) => {
|
||||
const breadcrumb = match.handle.breadcrumb as BreadcrumbItem;
|
||||
|
||||
return (
|
||||
<Fragment key={match.id}>
|
||||
{index !== 0 && <BreadcrumbIcon />} {breadcrumb(match, matches)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbLink({ title, to }: { title: string; to: string }) {
|
||||
return (
|
||||
<LinkButton to={to} variant="tertiary/small">
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
export function PageNavigationIndicator() {
|
||||
const navigation = useNavigation();
|
||||
if (navigation.state === "loading") {
|
||||
return <Spinner color="muted" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
CursorArrowRaysIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
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 { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
@@ -22,21 +28,33 @@ import {
|
||||
organizationBillingPath,
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
projectEventsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectSettingsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
v3ApiKeysPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectPath,
|
||||
v3RunsPath,
|
||||
v3TestPath,
|
||||
} 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,
|
||||
@@ -46,13 +64,20 @@ 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<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "hasInactiveExternalTriggers" | "jobCount" | "httpEndpointCount"
|
||||
| "id"
|
||||
| "name"
|
||||
| "slug"
|
||||
| "hasInactiveExternalTriggers"
|
||||
| "jobCount"
|
||||
| "httpEndpointCount"
|
||||
| "version"
|
||||
>;
|
||||
|
||||
type SideMenuProps = {
|
||||
@@ -66,6 +91,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 = () => {
|
||||
@@ -84,75 +110,32 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-ui-border transition"
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-background px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
"flex items-center justify-between px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<ProjectSelector organizations={organizations} project={project} />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
icon="plus"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
count={project.jobCount}
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
to={projectRunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
iconColor="text-amber-500"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
data-action="triggers"
|
||||
hasWarning={project.hasInactiveExternalTriggers}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
iconColor="text-green-500"
|
||||
count={project.httpEndpointCount}
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
iconColor="text-rose-500"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
{project.version === "V2" ? (
|
||||
<V2ProjectSideMenu organization={organization} project={project} />
|
||||
) : (
|
||||
<V3ProjectSideMenu organization={organization} project={project} />
|
||||
)}
|
||||
</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)}
|
||||
@@ -161,13 +144,15 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
{project.version === "V2" && (
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon="integration"
|
||||
to={organizationIntegrationsPath(organization)}
|
||||
data-action="integrations"
|
||||
hasWarning={organization.hasUnconfiguredIntegrations}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon="folder"
|
||||
@@ -185,12 +170,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">
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright 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-charcoal-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-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"
|
||||
@@ -219,6 +275,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>
|
||||
@@ -227,11 +289,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);
|
||||
@@ -249,10 +309,10 @@ 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"
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
@@ -260,27 +320,35 @@ 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-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>
|
||||
))}
|
||||
<div className="border-t border-slate-800 p-1">
|
||||
<div className="border-t border-charcoal-800 p-1">
|
||||
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon="plus" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -291,6 +359,7 @@ function ProjectSelector({
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const { v3Enabled } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
@@ -298,16 +367,16 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
|
||||
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1">
|
||||
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1 hover:bg-transparent">
|
||||
<UserProfilePhoto
|
||||
className={cn(
|
||||
"h-5 w-5 text-slate-600",
|
||||
"h-5 w-5 rounded-full border border-transparent text-charcoal-600 transition hover:border-charcoal-600",
|
||||
user.isImpersonating && "rounded-full border border-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
@@ -328,6 +397,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"
|
||||
@@ -341,98 +418,129 @@ function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
function V2ProjectSideMenu({
|
||||
project,
|
||||
organization,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
count?: number;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
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>
|
||||
<>
|
||||
<SideMenuHeader title={"Project"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
icon="plus"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
count={project.jobCount}
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
to={projectRunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
iconColor="text-amber-500"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
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-pink-500"
|
||||
count={project.httpEndpointCount}
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
iconColor="text-rose-500"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
return <div className="rounded-full bg-slate-900 px-2 py-1 text-xxs text-dimmed">{count}</div>;
|
||||
function V3ProjectSideMenu({
|
||||
project,
|
||||
organization,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"} />
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
count={project.jobCount}
|
||||
to={v3ProjectPath(organization, project)}
|
||||
data-action="tasks"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
iconColor="text-lime-500"
|
||||
to={v3TestPath(organization, project)}
|
||||
data-action="test"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API Keys"
|
||||
icon={KeyIcon}
|
||||
iconColor="text-amber-500"
|
||||
to={v3ApiKeysPath(organization, project)}
|
||||
data-action="api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
iconColor="text-pink-500"
|
||||
to={v3EnvironmentVariablesPath(organization, project)}
|
||||
data-action="environment variables"
|
||||
/>
|
||||
{/*
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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-charcoal-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
<EllipsisHorizontalIcon className="h-4 w-4 text-charcoal-500 transition group-hover:text-text-bright" />
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-max overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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-text-dimmed"}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750",
|
||||
subItem ? "text-text-dimmed" : "",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-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-charcoal-900 px-2 py-1 text-xxs text-text-dimmed">{count}</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const AlertOverlay = React.forwardRef<
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in",
|
||||
"fixed inset-0 z-50 bg-background-dimmed/80 backdrop-blur-sm transition-opacity animate-in fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -45,7 +45,7 @@ const AlertContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
|
||||
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background-dimmed p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -85,7 +85,7 @@ const AlertDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -3,9 +3,14 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
default:
|
||||
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-slate-700 text-bright uppercase whitespace-nowrap",
|
||||
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-charcoal-750 text-text-bright uppercase whitespace-nowrap",
|
||||
small:
|
||||
"grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap",
|
||||
outline:
|
||||
"grid place-items-center rounded-sm px-1 h-5 tracking-wider text-xxs border border-dimmed text-dimmed uppercase whitespace-nowrap",
|
||||
"grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap",
|
||||
v3: "grid place-items-center rounded-full px-[0.4rem] h-5 tracking-wider text-xxs bg-charcoal-750 text-primary whitespace-nowrap",
|
||||
"outline-rounded":
|
||||
"grid place-items-center rounded-full px-1.5 h-5 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap",
|
||||
};
|
||||
|
||||
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -15,7 +20,7 @@ type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
export function Badge({ className, variant = "default", children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(variants[variant], className)} {...props}>
|
||||
<span className="-mb-0.5">{children}</span>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "~/utils/cn";
|
||||
export function BreadcrumbIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("text-slate-650", className)}
|
||||
className={cn("text-charcoal-600", className)}
|
||||
width="9"
|
||||
height="20"
|
||||
viewBox="0 0 9 26"
|
||||
|
||||
@@ -1,160 +1,139 @@
|
||||
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
|
||||
import React, { ReactComponentElement, forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
button: "h-6 px-2.5 text-xs",
|
||||
icon: "h-3.5 -mx-1",
|
||||
iconSpacing: "gap-x-2.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut: "ml-2 -mr-1.5 justify-self-center",
|
||||
},
|
||||
medium: {
|
||||
button: "h-8 px-2.5 text-sm",
|
||||
icon: "h-4 -mx-1",
|
||||
iconSpacing: "gap-x-2.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-2 -mr-1.5 rounded justify-self-center",
|
||||
},
|
||||
large: {
|
||||
button: "h-10 px-2 text-sm font-medium",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5",
|
||||
},
|
||||
"extra-large": {
|
||||
button: "h-12 px-2 text-base font-medium",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
type Size = keyof typeof sizes;
|
||||
|
||||
const theme = {
|
||||
primary: {
|
||||
textColor:
|
||||
"text-charcoal-900 group-hover:text-charcoal-900 transition group-disabled:text-charcoal-900",
|
||||
button:
|
||||
"bg-primary group-hover:bg-apple-200 group-disabled:opacity-50 group-disabled:bg-primary group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
|
||||
button:
|
||||
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-tertiary group-hover:bg-charcoal-600 group-disabled:bg-tertiary group-disabled:opacity-60 group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
|
||||
},
|
||||
minimal: {
|
||||
textColor:
|
||||
"text-text-dimmed group-hover:text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-transparent group-hover:bg-tertiary disabled:opacity-50 group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
danger: {
|
||||
textColor:
|
||||
"text-text-bright group-hover:text-white transition group-disabled:text-text-bright/80",
|
||||
button:
|
||||
"bg-error group-hover:bg-rose-500 disabled:opacity-50 group-disabled:bg-error group-disabled:pointer-events-none",
|
||||
shortcut: "border-text-bright text-text-bright group-hover:border-bright/60",
|
||||
},
|
||||
};
|
||||
|
||||
type Theme = keyof typeof theme;
|
||||
|
||||
function createVariant(sizeName: Size, themeName: Theme) {
|
||||
return {
|
||||
textColor: theme[themeName].textColor,
|
||||
button: cn(sizes[sizeName].button, theme[themeName].button),
|
||||
icon: sizes[sizeName].icon,
|
||||
iconSpacing: sizes[sizeName].iconSpacing,
|
||||
shortcutVariant: sizes[sizeName].shortcutVariant,
|
||||
shortcut: cn(sizes[sizeName].shortcut, theme[themeName].shortcut),
|
||||
};
|
||||
}
|
||||
|
||||
const variant = {
|
||||
"primary/small": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60 justify-self-center",
|
||||
},
|
||||
"secondary/small": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
"tertiary/small": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-transparent group-hover:bg-slate-850 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut:
|
||||
"ml-1 -mr-0.5 border-dimmed/40 text-dimmed group-hover:text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
"danger/small": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button:
|
||||
"h-6 px-[5px] text-xs bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50 group-disabled:pointer-events-none",
|
||||
icon: "h-3.5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "small" as const,
|
||||
shortcut: "ml-1 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"primary/medium": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-indigo-600 group-hover:bg-indigo-500/90 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"secondary/medium": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut:
|
||||
"ml-1.5 -mr-0.5 border-dimmed/40 text-dimmed group-hover:border-dimmed group-hover:text-bright",
|
||||
},
|
||||
"tertiary/medium": {
|
||||
textColor: "text-dimmed group-hover:text-bright transition group-disabled:text-dimmed/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-transparent group-hover:bg-slate-850 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut:
|
||||
"ml-1.5 -mr-0.5 border-bright/40 text-dimmed group-hover:border-bright/60 group-hover:text-bright",
|
||||
},
|
||||
"danger/medium": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/80 px-1",
|
||||
button: "h-8 px-2 text-sm bg-rose-600 group-hover:bg-rose-500 disabled:opacity-50",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"primary/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-dimmed/80 px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm font-medium bg-indigo-600 group-hover:bg-indigo-500/90 group-disabled:opacity-50",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"secondary/large": {
|
||||
textColor: "text-dimmed px-1",
|
||||
button:
|
||||
"h-10 px-2 text-sm text-dimmed group-hover:text-bright transition font-medium bg-slate-800 group-hover:bg-slate-700/70 disabled:opacity-50",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"danger/large": {
|
||||
textColor: "text-bright group-hover:text-white transition group-disabled:text-bright/50 px-1",
|
||||
button:
|
||||
"h-10 px-2 text-md bg-rose-600 group-hover:bg-rose-500 group-disabled:opacity-50 group-disabled:group-hover:bg-rose-600",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: "medium" as const,
|
||||
shortcut: "ml-1.5 -mr-0.5 border-bright/40 text-bright group-hover:border-bright/60",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
"primary/small": createVariant("small", "primary"),
|
||||
"primary/medium": createVariant("medium", "primary"),
|
||||
"primary/large": createVariant("large", "primary"),
|
||||
"primary/extra-large": createVariant("extra-large", "primary"),
|
||||
"secondary/small": createVariant("small", "secondary"),
|
||||
"secondary/medium": createVariant("medium", "secondary"),
|
||||
"secondary/large": createVariant("large", "secondary"),
|
||||
"secondary/extra-large": createVariant("extra-large", "secondary"),
|
||||
"tertiary/small": createVariant("small", "tertiary"),
|
||||
"tertiary/medium": createVariant("medium", "tertiary"),
|
||||
"tertiary/large": createVariant("large", "tertiary"),
|
||||
"tertiary/extra-large": createVariant("extra-large", "tertiary"),
|
||||
"minimal/small": createVariant("small", "minimal"),
|
||||
"minimal/medium": createVariant("medium", "minimal"),
|
||||
"minimal/large": createVariant("large", "minimal"),
|
||||
"minimal/extra-large": createVariant("extra-large", "minimal"),
|
||||
"danger/small": createVariant("small", "danger"),
|
||||
"danger/medium": createVariant("medium", "danger"),
|
||||
"danger/large": createVariant("large", "danger"),
|
||||
"danger/extra-large": createVariant("extra-large", "danger"),
|
||||
"menu-item": {
|
||||
textColor: "text-bright px-1",
|
||||
textColor: "text-text-bright px-1",
|
||||
button:
|
||||
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-slate-800 transition",
|
||||
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-800 transition",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"small-menu-item": {
|
||||
textColor: "text-bright",
|
||||
textColor: "text-text-bright",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-850 transition",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-1.5",
|
||||
shortcutVariant: undefined,
|
||||
shortcut: undefined,
|
||||
},
|
||||
"small-menu-sub-item": {
|
||||
textColor: "text-dimmed",
|
||||
textColor: "text-text-dimmed",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-dimmed bg-transparent group-hover:bg-slate-850 transition",
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-850 transition",
|
||||
icon: undefined,
|
||||
iconSpacing: undefined,
|
||||
shortcutVariant: undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BookOpenIcon,
|
||||
ChartBarIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
@@ -17,46 +18,52 @@ import { Spinner } from "./Spinner";
|
||||
|
||||
export const variantClasses = {
|
||||
info: {
|
||||
className: "border-slate-700 bg-slate-800",
|
||||
icon: <InformationCircleIcon className="h-5 w-5 shrink-0 text-dimmed" />,
|
||||
textColor: "text-bright",
|
||||
linkClassName: "transition hover:bg-slate-750",
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <InformationCircleIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
},
|
||||
warning: {
|
||||
className: "border-yellow-400/20 bg-yellow-400/30",
|
||||
icon: <ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-yellow-400" />,
|
||||
className: "border-warning/20 bg-warning/10",
|
||||
icon: <ExclamationTriangleIcon className="h-5 w-5 shrink-0 text-warning" />,
|
||||
textColor: "text-yellow-200",
|
||||
linkClassName: "transition hover:bg-yellow-400/40",
|
||||
linkClassName: "transition hover:bg-warning/20",
|
||||
},
|
||||
error: {
|
||||
className: "border-rose-500/20 bg-rose-500/30",
|
||||
icon: <ExclamationCircleIcon className="h-5 w-5 shrink-0 text-rose-400" />,
|
||||
className: "border-error/20 bg-error/10",
|
||||
icon: <ExclamationCircleIcon className="h-5 w-5 shrink-0 text-error" />,
|
||||
textColor: "text-rose-200",
|
||||
linkClassName: "transition hover:bg-rose-500/40",
|
||||
linkClassName: "transition hover:bg-error/20",
|
||||
},
|
||||
idea: {
|
||||
className: "border-green-400/20 bg-green-400/30",
|
||||
icon: <LightBulbIcon className="h-5 w-5 shrink-0 text-green-400" />,
|
||||
className: "border-success/20 bg-success/10",
|
||||
icon: <LightBulbIcon className="h-5 w-5 shrink-0 text-success" />,
|
||||
textColor: "text-green-200",
|
||||
linkClassName: "transition hover:bg-green-400/40",
|
||||
linkClassName: "transition hover:bg-success/20",
|
||||
},
|
||||
success: {
|
||||
className: "border-green-400/20 bg-green-400/30",
|
||||
icon: <CheckCircleIcon className="h-5 w-5 shrink-0 text-green-400" />,
|
||||
className: "border-success/20 bg-success/10",
|
||||
icon: <CheckCircleIcon className="h-5 w-5 shrink-0 text-success" />,
|
||||
textColor: "text-green-200",
|
||||
linkClassName: "transition hover:bg-green-400/40",
|
||||
linkClassName: "transition hover:bg-success/20",
|
||||
},
|
||||
docs: {
|
||||
className: "border-blue-400/20 bg-blue-400/30",
|
||||
className: "border-blue-400/20 bg-blue-400/10",
|
||||
icon: <BookOpenIcon className="mt-0.5 h-5 w-5 shrink-0 text-blue-400" />,
|
||||
textColor: "text-blue-200",
|
||||
linkClassName: "transition hover:bg-blue-400/40",
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pending: {
|
||||
className: "border-blue-400/20 bg-blue-800/30",
|
||||
icon: <Spinner className="h-5 w-5 shrink-0 " />,
|
||||
textColor: "text-blue-300",
|
||||
linkClassName: "transition hover:bg-blue-400/40",
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -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;
|
||||
}) {
|
||||
@@ -126,7 +135,9 @@ export function Callout({
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5", variantDefinition.textColor)} />
|
||||
<div className="flex h-full items-center">
|
||||
<ChevronRightIcon className={cn("h-5 w-5", variantDefinition.textColor)} />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -135,20 +146,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-start 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-start 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,44 +7,44 @@ import { Paragraph } from "./Paragraph";
|
||||
const variants = {
|
||||
"simple/small": {
|
||||
button: "w-fit pr-4",
|
||||
label: "text-sm text-bright mt-0.5 select-none",
|
||||
description: "text-dimmed",
|
||||
label: "text-sm text-text-bright mt-0.5 select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
isChecked: "",
|
||||
isDisabled: "opacity-70",
|
||||
},
|
||||
simple: {
|
||||
button: "w-fit pr-4",
|
||||
label: "text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
label: "text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
isChecked: "",
|
||||
isDisabled: "opacity-70",
|
||||
},
|
||||
"button/small": {
|
||||
button:
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-slate-800 hover:bg-slate-850 hover:border-slate-750 transition",
|
||||
label: "text-sm text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
"flex items-center w-fit h-8 pl-2 pr-3 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
|
||||
label: "text-sm text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-0",
|
||||
isChecked: "bg-slate-850 border-slate-750 hover:!bg-slate-850",
|
||||
isChecked: "bg-charcoal-850 border-charcoal-750 hover:!bg-charcoal-850",
|
||||
isDisabled: "opacity-70 hover:bg-transparent",
|
||||
},
|
||||
button: {
|
||||
button:
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-slate-800 hover:bg-slate-850 hover:border-slate-750 transition",
|
||||
label: "text-bright select-none",
|
||||
description: "text-dimmed",
|
||||
"w-fit py-2 pl-3 pr-4 rounded border border-charcoal-800 hover:bg-charcoal-850 hover:border-charcoal-750 transition",
|
||||
label: "text-text-bright select-none",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
isChecked: "bg-slate-850 border-slate-750 hover:!bg-slate-850",
|
||||
isChecked: "bg-charcoal-850 border-charcoal-750 hover:!bg-charcoal-850",
|
||||
isDisabled: "opacity-70 hover:bg-transparent",
|
||||
},
|
||||
description: {
|
||||
button: "w-full py-2 pl-3 pr-4 checked:hover:bg-slate-850 transition",
|
||||
label: "text-bright font-semibold",
|
||||
description: "text-dimmed",
|
||||
button: "w-full py-2 pl-3 pr-4 checked:hover:bg-charcoal-850 transition",
|
||||
label: "text-text-bright font-semibold",
|
||||
description: "text-text-dimmed",
|
||||
inputPosition: "mt-1",
|
||||
isChecked: "bg-slate-850",
|
||||
isChecked: "bg-charcoal-850",
|
||||
isDisabled: "opacity-70",
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-700 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-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:!bg-charcoal-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;
|
||||
|
||||
@@ -25,7 +26,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap border-r border-slate-700 px-2 text-sm ring-offset-background transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-dimmed data-[state=inactive]:hover:text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,7 +41,7 @@ const ClientTabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-1 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -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-charcoal-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-charcoal-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-charcoal-500 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
)}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
})}
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export { ClientTabs, ClientTabsList, ClientTabsTrigger, ClientTabsContent };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user