Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a66ac021 | |||
| 7d34817473 | |||
| 6d6ed471d1 | |||
| 7f7f993587 | |||
| d28707826c | |||
| 8b00198f99 | |||
| 74e9246bfa | |||
| 07a31d3732 | |||
| da111e220f | |||
| 2c3cb4a43a | |||
| b71bf89444 | |||
| 28c0c78257 | |||
| c021d1db63 | |||
| f62cdfe00e | |||
| 66c6da7114 | |||
| 7fba9e9f6b | |||
| cf63fc9cd2 | |||
| d1c3bfb9c9 | |||
| d8f5853457 | |||
| a52566d9cc | |||
| 07a1d04d52 | |||
| 185f4ecaf9 | |||
| 0d764d4c46 | |||
| beb52b9800 | |||
| 3401a1d0a9 | |||
| 3f982ed366 | |||
| 4dc956470d | |||
| 7fddadcce9 | |||
| 6e038d4d1e | |||
| 05b53967ea | |||
| 702f3b4bca | |||
| 8fcd93001d | |||
| b01b8740cc | |||
| 357aa99309 | |||
| 7cbf82a4ae | |||
| e7fec4097f | |||
| d279988e38 | |||
| 748ae658f7 | |||
| 249878ed92 | |||
| 117b1d5a53 | |||
| 04173a93b9 | |||
| da4c753b68 | |||
| 6ae3b69745 | |||
| 652d95c7eb | |||
| 341e27d213 | |||
| 9821d02af7 | |||
| 255a73a2fe | |||
| c5f7a8daf7 | |||
| 8b0f51b317 | |||
| 53f21e1330 | |||
| 331882f59c | |||
| 1276491a83 | |||
| 5b7dfe23b5 | |||
| 49b2f683f4 | |||
| af9b3e1c99 | |||
| df4ab97d59 | |||
| 9f27422472 | |||
| 2f1a72b109 | |||
| 3c326a4b4a | |||
| 6ae1317b69 | |||
| 2e1c4f6df6 | |||
| 2bf86dc20e | |||
| 485782cae1 | |||
| 61b338bea7 | |||
| 83ddf721a4 | |||
| a4dd2562d2 | |||
| 5ff21a758c | |||
| 47a64c0335 | |||
| fc351cb6c4 | |||
| c4f2a9d065 | |||
| 2762c542c2 | |||
| 72e286af2f | |||
| f7240a99e7 | |||
| 6e47377766 | |||
| a8563ca534 | |||
| 5e5c97ea4c | |||
| abd99aa27f | |||
| 4347499799 | |||
| abee783d3f | |||
| 6464eeed53 | |||
| bee59de3a0 | |||
| 3af7303156 | |||
| 6231ddc67a | |||
| 15fef916f6 | |||
| e2a703bfa8 | |||
| 1a7ee24b9e | |||
| bb99af52cb | |||
| 01797a1668 | |||
| 19fa669318 | |||
| 78fcba518a | |||
| 53047ab648 | |||
| bb5cefa92c | |||
| b8b198579c | |||
| 892bed8c4c | |||
| a94a11f44d | |||
| f116e93e01 | |||
| 6137338da9 | |||
| 8cec3b763b | |||
| 343ba54c69 | |||
| a70ab10809 | |||
| f7cb637b32 | |||
| 668559ec1a | |||
| d0ad38d684 | |||
| 536d9fa217 | |||
| d75c3aeadd | |||
| a342332146 | |||
| 9624465ee2 | |||
| 42f53b12b7 | |||
| 9fdf91a1c4 | |||
| 9593a46364 | |||
| 27376df903 | |||
| e0cece72ae | |||
| 2f3f82f3a9 | |||
| 4264bdf429 | |||
| bf6735bd56 | |||
| dacd53b906 | |||
| d4fe71df34 | |||
| ae8e83b2d0 | |||
| 2283ca6ad3 | |||
| 8fdbbeb02f | |||
| 255ea0a4b3 | |||
| 7f25e82299 | |||
| d90da7abf7 | |||
| 2affe541e8 | |||
| 3157b657c7 | |||
| 41bdab58d5 | |||
| fe3fe01fe8 | |||
| 68d0037e60 | |||
| 885d2d3560 | |||
| a445af1b79 | |||
| 5781783c74 | |||
| cca10c22d3 | |||
| 63b6fc93fa | |||
| aa66462971 | |||
| f6461684ad | |||
| a6896b411a | |||
| 0cabbdd31f | |||
| f8977a7b70 | |||
| f0643f76f5 | |||
| 416dbcd536 | |||
| 679b41dc7e | |||
| be98aecbfd | |||
| 129dc02f2a | |||
| 8917478d3c | |||
| f5caa66348 | |||
| 64fcc88fa7 | |||
| 23dbe282ed | |||
| 692316e82a | |||
| 107f4dc87c | |||
| b90f3e2173 | |||
| b3b2553651 | |||
| cdd1a8838c | |||
| 200b7354d0 | |||
| eeed38d223 | |||
| 0ca092651b | |||
| 53acdf8ef5 | |||
| 128bc437f6 | |||
| 0597691001 | |||
| dae84a0d29 | |||
| f72d63aac2 | |||
| 12cceaa779 | |||
| ddebe4dce0 | |||
| 09d51c6d24 | |||
| 3ceea774a8 | |||
| 05b6a26c4f | |||
| 558fb11b89 | |||
| 9aedda23a4 | |||
| 743b8dbe0c | |||
| eb0263e942 | |||
| 7bf579fa50 | |||
| 69d52db856 | |||
| a3cea1302e | |||
| a3bdd3c64b | |||
| 6d6e98aa11 | |||
| e22c321dd1 | |||
| 59df4af1eb | |||
| 8863ff05c9 | |||
| d10281e655 | |||
| 6798d57e72 | |||
| 480c0d34d3 | |||
| 700a6ea598 | |||
| cc94d121f2 | |||
| 412e80fdde | |||
| 49728b5a5f | |||
| d45696c000 | |||
| 8313800746 | |||
| 28f8cee3a4 | |||
| 87b3603b23 | |||
| 365adc24a6 | |||
| 7d17730b52 | |||
| e4982bfd6d | |||
| a03783d1a0 | |||
| 0178bdbb00 |
@@ -0,0 +1,11 @@
|
||||
# Remove AI code slop
|
||||
|
||||
Check the diff against main, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
|
||||
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
|
||||
- Casts to any to get around type issues
|
||||
- Any other style that is inconsistent with the file
|
||||
|
||||
Report at the end with only a 1-3 sentence summary of what you changed
|
||||
+7
-2
@@ -13,6 +13,11 @@ APP_ORIGIN=http://localhost:3030
|
||||
ELECTRIC_ORIGIN=http://localhost:3060
|
||||
NODE_ENV=development
|
||||
|
||||
# Clickhouse
|
||||
CLICKHOUSE_URL=http://default:password@localhost:8123
|
||||
RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123
|
||||
RUN_REPLICATION_ENABLED=1
|
||||
|
||||
# Set this to UTC because Node.js uses the system timezone
|
||||
TZ="UTC"
|
||||
|
||||
@@ -29,9 +34,9 @@ DEPLOY_REGISTRY_HOST=localhost:5000
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
name: 🦋 Changesets PR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-pr:
|
||||
name: Create Release PR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Create release PR
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: release"
|
||||
title: "chore: release"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number')
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch origin changeset-release/main
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
name: Update lockfile on release PR
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pr
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: changeset-release/main
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
git push origin changeset-release/main
|
||||
fi
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
+135
-48
@@ -1,98 +1,185 @@
|
||||
name: 🦋 Changesets Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/CODEOWNERS"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
type:
|
||||
description: "Select release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- prerelease
|
||||
default: "prerelease"
|
||||
ref:
|
||||
description: "The ref (branch, tag, or SHA) to checkout and release from"
|
||||
required: true
|
||||
type: string
|
||||
prerelease_tag:
|
||||
description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')"
|
||||
required: false
|
||||
type: string
|
||||
default: "prerelease"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
show-release-summary:
|
||||
name: 📋 Release Summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.head.ref == 'changeset-release/main'
|
||||
steps:
|
||||
- name: Show release summary
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: 🚀 Release npm packages
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
id-token: write
|
||||
if: |
|
||||
github.repository == 'triggerdotdev/trigger.dev' &&
|
||||
(
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') ||
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main')
|
||||
)
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
published_package_version: ${{ steps.get_version.outputs.package_version }}
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }}
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
- name: Verify ref is on main
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git merge-base --is-ancestor ${{ github.event.inputs.ref }} origin/main; then
|
||||
echo "Error: ref must be an ancestor of main (i.e., already merged)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
- name: Generate Prisma client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔎 Type check
|
||||
- name: Type check
|
||||
run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: 🔐 Setup npm auth
|
||||
run: |
|
||||
echo "registry=https://registry.npmjs.org" >> ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc
|
||||
|
||||
# This action has two responsibilities. The first time the workflow runs
|
||||
# (initial push to the `main` branch) it will create a new branch and
|
||||
# then open a PR with the related changes for the new version. After the
|
||||
# PR is merged, the workflow will run again and this action will build +
|
||||
# publish to npm.
|
||||
- name: 🚀 PR / Publish
|
||||
if: ${{ !env.ACT }}
|
||||
- name: Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm run changeset:version
|
||||
commit: "chore: Update version for release"
|
||||
title: "chore: Update version for release"
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# - name: 🚀 PR / Publish (mock)
|
||||
# if: ${{ env.ACT }}
|
||||
# id: changesets
|
||||
# run: |
|
||||
# echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
# echo "publishedPackages=[{\"name\": \"@xx/xx\", \"version\": \"1.2.0\"}, {\"name\": \"@xx/xy\", \"version\": \"0.8.9\"}]" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 📦 Get package version
|
||||
- name: Show package version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: get_version
|
||||
run: |
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
set -e
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# npm v11.5.1 or newer is required for OIDC support
|
||||
# https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new
|
||||
- name: Setup npm 11.x for OIDC
|
||||
run: npm install -g npm@11.6.4
|
||||
|
||||
- name: Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: Snapshot version
|
||||
run: pnpm exec changeset version --snapshot ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: Publish prerelease
|
||||
run: pnpm exec changeset publish --no-git-tag --snapshot --tag ${{ github.event.inputs.prerelease_tag }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
|
||||
- name: 🔎 Type check
|
||||
run: pnpm run typecheck
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: 🔎 Check exports
|
||||
run: pnpm run check-exports
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
cache: "pnpm"
|
||||
|
||||
# ..to avoid rate limits when pulling images
|
||||
@@ -119,12 +119,12 @@ jobs:
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
node-version: 20.19.0
|
||||
# no cache enabled, we're not installing deps
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
|
||||
+3
-5
@@ -29,12 +29,10 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.docker
|
||||
.env
|
||||
.env.*
|
||||
.docker/*.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!.env.example
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
update-notifier=false
|
||||
side-effects-cache=false
|
||||
Vendored
+2
-1
@@ -6,5 +6,6 @@
|
||||
"**/node_modules/**": true,
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": false
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ This repository is a pnpm monorepo managed with Turbo. It contains multiple apps
|
||||
See `ai/references/repo.md` for a more complete explanation of the workspaces.
|
||||
|
||||
## Development setup
|
||||
1. Install dependencies with `pnpm i` (pnpm `8.15.5` and Node.js `20.11.1` are required).
|
||||
1. Install dependencies with `pnpm i` (pnpm `10.23.0` and Node.js `20.11.1` are required).
|
||||
2. Copy `.env.example` to `.env` and generate a random 16 byte hex string for `ENCRYPTION_KEY` (`openssl rand -hex 16`). Update other secrets if needed.
|
||||
3. Start the local services with Docker:
|
||||
```bash
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ branch are tagged into a release periodically.
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 10.23.0
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
@@ -36,7 +36,7 @@ branch are tagged into a release periodically.
|
||||
```
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`10.23.0`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
[](https://twitter.com/triggerdotdev)
|
||||
[](https://discord.gg/nkqV9xBYWy)
|
||||
[](https://deepwiki.com/triggerdotdev/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Repo Overview
|
||||
|
||||
This is a pnpm 8.15.5 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
This is a pnpm 10.23.0 monorepo that uses turborepo @turbo.json. The following workspaces are relevant
|
||||
|
||||
## Apps
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
||||
FROM node-22-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=supervisor --docker
|
||||
RUN npx -q turbo@2.5.4 prune --scope=supervisor --docker
|
||||
|
||||
FROM node-22-alpine AS base
|
||||
|
||||
@@ -16,7 +16,7 @@ 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
|
||||
|
||||
RUN corepack enable && corepack prepare --activate
|
||||
RUN corepack enable && corepack prepare pnpm@10.23.0 --activate
|
||||
|
||||
FROM base AS deps-fetcher
|
||||
RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers
|
||||
@@ -37,7 +37,7 @@ COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts
|
||||
|
||||
RUN pnpm run generate && \
|
||||
pnpm run --filter supervisor... build&& \
|
||||
pnpm deploy --filter=supervisor --prod /prod/supervisor
|
||||
pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ const Env = z.object({
|
||||
RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(),
|
||||
|
||||
// Docker settings
|
||||
DOCKER_API_VERSION: z.string().default("v1.41"),
|
||||
DOCKER_API_VERSION: z.string().optional(),
|
||||
DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64
|
||||
DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true),
|
||||
DOCKER_REGISTRY_USERNAME: z.string().optional(),
|
||||
@@ -90,6 +90,7 @@ const Env = z.object({
|
||||
KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0),
|
||||
KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit
|
||||
KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
|
||||
@@ -244,6 +244,12 @@ class ManagedSupervisor {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!message.deployment.friendlyId) {
|
||||
// mostly a type guard, deployments always exists for deployed environments
|
||||
// a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
|
||||
throw new Error("Deployment is missing");
|
||||
}
|
||||
|
||||
await this.workloadManager.create({
|
||||
dequeuedAt: message.dequeuedAt,
|
||||
envId: message.environment.id,
|
||||
@@ -252,6 +258,8 @@ class ManagedSupervisor {
|
||||
machine: message.run.machine,
|
||||
orgId: message.organization.id,
|
||||
projectId: message.project.id,
|
||||
deploymentFriendlyId: message.deployment.friendlyId,
|
||||
deploymentVersion: message.backgroundWorker.version,
|
||||
runId: message.run.id,
|
||||
runFriendlyId: message.run.friendlyId,
|
||||
version: message.version,
|
||||
|
||||
@@ -25,6 +25,7 @@ export class FailedPodHandler {
|
||||
|
||||
private readonly informer: Informer<V1Pod>;
|
||||
private readonly reconnectIntervalMs: number;
|
||||
private reconnecting = false;
|
||||
|
||||
// Metrics
|
||||
private readonly register: Registry;
|
||||
@@ -250,21 +251,48 @@ export class FailedPodHandler {
|
||||
}
|
||||
|
||||
private makeOnError(informerName: string) {
|
||||
return () => this.onError(informerName);
|
||||
return (err?: unknown) => this.onError(informerName, err);
|
||||
}
|
||||
|
||||
private async onError(informerName: string) {
|
||||
private async onError(informerName: string, err?: unknown) {
|
||||
if (!this.isRunning) {
|
||||
this.logger.warn("onError: informer not running");
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error("error event fired", { informerName });
|
||||
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
|
||||
// Guard against multiple simultaneous reconnections
|
||||
if (this.reconnecting) {
|
||||
this.logger.debug("onError: reconnection already in progress, skipping", {
|
||||
informerName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect on errors
|
||||
await setTimeout(this.reconnectIntervalMs);
|
||||
await this.informer.start();
|
||||
this.reconnecting = true;
|
||||
|
||||
try {
|
||||
const error = err instanceof Error ? err : undefined;
|
||||
this.logger.error("error event fired", {
|
||||
informerName,
|
||||
error: error?.message,
|
||||
errorType: error?.name,
|
||||
});
|
||||
this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" });
|
||||
|
||||
// Reconnect on errors
|
||||
await setTimeout(this.reconnectIntervalMs);
|
||||
await this.informer.start();
|
||||
} catch (handlerError) {
|
||||
const error = handlerError instanceof Error ? handlerError : undefined;
|
||||
this.logger.error("onError: reconnection attempt failed", {
|
||||
informerName,
|
||||
error: error?.message,
|
||||
errorType: error?.name,
|
||||
errorStack: error?.stack,
|
||||
});
|
||||
} finally {
|
||||
this.reconnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private makeOnConnect(informerName: string) {
|
||||
|
||||
@@ -72,6 +72,8 @@ export class DockerWorkloadManager implements WorkloadManager {
|
||||
`TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`,
|
||||
`TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`,
|
||||
`TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`,
|
||||
`TRIGGER_RUN_ID=${opts.runFriendlyId}`,
|
||||
`TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`,
|
||||
`TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`,
|
||||
|
||||
@@ -123,6 +123,14 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
name: "TRIGGER_ENV_ID",
|
||||
value: opts.envId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_ID",
|
||||
value: opts.deploymentFriendlyId,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_DEPLOYMENT_VERSION",
|
||||
value: opts.deploymentVersion,
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_SNAPSHOT_ID",
|
||||
value: opts.snapshotFriendlyId,
|
||||
@@ -274,6 +282,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
imagePullSecrets: this.getImagePullSecrets(),
|
||||
...(env.KUBERNETES_SCHEDULER_NAME
|
||||
? {
|
||||
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
|
||||
}
|
||||
: {}),
|
||||
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
|
||||
? {
|
||||
nodeSelector: {
|
||||
@@ -302,6 +315,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
envtype: this.#envTypeToLabelValue(opts.envType),
|
||||
org: opts.orgId,
|
||||
project: opts.projectId,
|
||||
machine: opts.machine.name,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface WorkloadManagerCreateOptions {
|
||||
envType: EnvironmentType;
|
||||
orgId: string;
|
||||
projectId: string;
|
||||
deploymentFriendlyId: string;
|
||||
deploymentVersion: string;
|
||||
runId: string;
|
||||
runFriendlyId: string;
|
||||
snapshotId: string;
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.env
|
||||
@@ -9,7 +9,8 @@ node_modules
|
||||
|
||||
/app/styles/tailwind.css
|
||||
|
||||
|
||||
# Ensure the .env symlink is not removed by accident
|
||||
!.env
|
||||
|
||||
# Storybook build outputs
|
||||
build-storybook.log
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export function ConcurrencyIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="3.75" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="14.25" cy="3.75" r="2.25" fill="currentColor" />
|
||||
<circle cx="3.75" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="9" r="2.25" fill="currentColor" />
|
||||
<circle cx="9" cy="14.25" r="1.75" stroke="currentColor" />
|
||||
<circle cx="14.25" cy="9" r="2.25" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function ListBulletIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9 5H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 19H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="4" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function MoveToBottomIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15L12 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L21 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12.5L12 17L16.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function SnakedArrowIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5 5H16C17.6569 5 19 6.34315 19 8L19 8.5C19 10.1569 17.6569 11.5 16 11.5H8C6.34314 11.5 5 12.8431 5 14.5L5 15C4.99999 16.6569 6.34314 18 8 18H18.634"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 21L19 18L16 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function StreamsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 19C3 19 5.01155 17 8 17C10.9885 17 13 18.9973 16 18.9973C19 18.9973 21 17 21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 13.0001C3 13.0001 5.01155 11 8 11C10.9885 11 13 13 16 13C19 13 21 11.0001 21 11.0001" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 7C3 7 5.01155 5 8 5C10.9885 5 13 6.9973 16 6.9973C19 6.9973 21 5 21 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function GoogleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
|
||||
fill="#FBBC02"
|
||||
/>
|
||||
<path
|
||||
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
@@ -93,62 +100,7 @@ export function HasNoTasksDev() {
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1a" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment. Read
|
||||
the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="1b" title="Or deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
@@ -266,45 +218,7 @@ export function TestHasNoTasks() {
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous Integration
|
||||
service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
@@ -313,46 +227,52 @@ export function DeploymentsNoneDev() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-deployments"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI or a Continuous
|
||||
Integration service like GitHub Actions. Make sure you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-charcoal-600 bg-secondary hover:border-charcoal-550 hover:bg-charcoal-600"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,3 +590,99 @@ export function BulkActionsNone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
@@ -23,10 +23,12 @@ import { DialogClose } from "@radix-ui/react-dialog";
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
@@ -52,8 +54,26 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
}
|
||||
}, [navigation, form]);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const open = searchParams.get("feedbackPanel");
|
||||
if (open) {
|
||||
setType(open as FeedbackType);
|
||||
setOpen(true);
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("feedbackPanel");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
onOpenChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Contact us</DialogHeader>
|
||||
|
||||
@@ -134,6 +134,10 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to adjacent">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
|
||||
@@ -22,6 +22,7 @@ export function UserAvatar({
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
UsersIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
accountPath,
|
||||
adminPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
@@ -121,6 +124,7 @@ export function SideMenu({
|
||||
const { isConnected } = useDevPresence();
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -312,6 +316,15 @@ export function SideMenu({
|
||||
data-action="preview-branches"
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
@@ -397,9 +410,15 @@ function ProjectSelector({
|
||||
>
|
||||
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="box-content size-10 overflow-clip rounded-sm bg-charcoal-800">
|
||||
<Link
|
||||
to={organizationSettingsPath(organization)}
|
||||
className="group relative box-content size-10 overflow-clip rounded-sm bg-charcoal-800"
|
||||
>
|
||||
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
|
||||
</div>
|
||||
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
|
||||
<PencilSquareIcon className="size-5 text-text-bright" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="space-y-0.5">
|
||||
<Paragraph variant="small/bright">{organization.title}</Paragraph>
|
||||
<div className="flex items-baseline gap-2">
|
||||
|
||||
@@ -276,7 +276,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
|
||||
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
|
||||
<TooltipContent className="flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs text-text-bright">
|
||||
{tooltip} {shortcut && renderShortcutKey()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -298,19 +298,17 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
|
||||
|
||||
if (props.shortcut) {
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: (e) => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
disabled,
|
||||
});
|
||||
}
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: (e) => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
disabled: disabled || !props.shortcut,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -333,7 +331,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
type LinkPropsType = Pick<
|
||||
LinkProps,
|
||||
"to" | "target" | "onClick" | "onMouseDown" | "onMouseEnter" | "onMouseLeave" | "download"
|
||||
> & { disabled?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
> & { disabled?: boolean; replace?: boolean } & React.ComponentProps<typeof ButtonContent>;
|
||||
export const LinkButton = ({
|
||||
to,
|
||||
onClick,
|
||||
@@ -342,19 +340,20 @@ export const LinkButton = ({
|
||||
onMouseLeave,
|
||||
download,
|
||||
disabled = false,
|
||||
replace,
|
||||
...props
|
||||
}: LinkPropsType) => {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
if (props.shortcut) {
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: () => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: () => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.click();
|
||||
}
|
||||
},
|
||||
disabled: disabled || !props.shortcut,
|
||||
});
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
@@ -374,7 +373,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -389,7 +388,8 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
replace={replace}
|
||||
className={cn("group/button block focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -410,7 +410,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none block", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -1,41 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type Variants } from "./Tabs";
|
||||
|
||||
type ClientTabsContextValue = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const ClientTabsContext = React.createContext<ClientTabsContextValue | undefined>(undefined);
|
||||
|
||||
function useClientTabsContext() {
|
||||
return React.useContext(ClientTabsContext);
|
||||
}
|
||||
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
>(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => {
|
||||
const [value, setValue] = React.useState<string | undefined>(valueProp ?? defaultValue);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (valueProp !== undefined) {
|
||||
setValue(valueProp);
|
||||
}
|
||||
}, [valueProp]);
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValue(nextValue);
|
||||
}
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, valueProp]
|
||||
);
|
||||
|
||||
const controlledProps =
|
||||
valueProp !== undefined
|
||||
? { value: valueProp }
|
||||
: defaultValue !== undefined
|
||||
? { defaultValue }
|
||||
: {};
|
||||
|
||||
const contextValue = React.useMemo<ClientTabsContextValue>(() => ({ value }), [value]);
|
||||
|
||||
return (
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
/>
|
||||
</ClientTabsContext.Provider>
|
||||
);
|
||||
});
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center justify-center transition duration-100", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
|
||||
variant?: Variants;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", ...props }, ref) => {
|
||||
const variantClassName = (() => {
|
||||
switch (variant) {
|
||||
case "segmented":
|
||||
return "relative flex h-10 w-full items-center rounded bg-charcoal-700/50 p-1";
|
||||
case "underline":
|
||||
return "flex gap-x-6 border-b border-grid-bright";
|
||||
default:
|
||||
return "inline-flex items-center justify-center transition duration-100";
|
||||
}
|
||||
})();
|
||||
|
||||
return <TabsPrimitive.List ref={ref} className={cn(variantClassName, className)} {...props} />;
|
||||
});
|
||||
ClientTabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const ClientTabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> & {
|
||||
variant?: Variants;
|
||||
layoutId?: string;
|
||||
}
|
||||
>(({ className, variant = "pipe-divider", layoutId, children, ...props }, ref) => {
|
||||
const context = useClientTabsContext();
|
||||
const activeValue = context?.value;
|
||||
const isActive = activeValue === props.value;
|
||||
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{isActive ? (
|
||||
layoutId ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600" />
|
||||
)
|
||||
) : null}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{layoutId ? (
|
||||
isActive ? (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
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" />
|
||||
)
|
||||
) : isActive ? (
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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}
|
||||
>
|
||||
{children}
|
||||
</TabsPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
ClientTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const ClientTabsContent = React.forwardRef<
|
||||
@@ -61,39 +205,7 @@ export type TabsProps = {
|
||||
currentValue: string;
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
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 };
|
||||
export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger };
|
||||
|
||||
@@ -3,55 +3,95 @@ import { useState } from "react";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useCopy } from "~/hooks/useCopy";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "./Buttons";
|
||||
|
||||
export function CopyableText({
|
||||
value,
|
||||
copyValue,
|
||||
className,
|
||||
asChild,
|
||||
variant,
|
||||
}: {
|
||||
value: string;
|
||||
copyValue?: string;
|
||||
className?: string;
|
||||
asChild?: boolean;
|
||||
variant?: "icon-right" | "text-below";
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { copy, copied } = useCopy(copyValue ?? value);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
const resolvedVariant = variant ?? "icon-right";
|
||||
|
||||
if (resolvedVariant === "icon-right") {
|
||||
return (
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
className={cn("group relative inline-flex h-6 items-center", className)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
/>
|
||||
<span onMouseEnter={() => setIsHovered(true)}>{value}</span>
|
||||
<span
|
||||
onClick={copy}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"absolute -right-6 top-0 z-10 size-6 font-sans",
|
||||
isHovered ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
|
||||
asChild && "p-1",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon className="size-3.5" />
|
||||
) : (
|
||||
<ClipboardIcon className="size-3.5" />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
content={copied ? "Copied!" : "Copy"}
|
||||
className="font-sans"
|
||||
disableHoverableContent
|
||||
asChild={asChild}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedVariant === "text-below") {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer bg-transparent py-0 px-1 text-left text-text-bright transition-colors hover:text-white hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
</Button>
|
||||
}
|
||||
content={copied ? "Copied" : "Click to copy"}
|
||||
className="font-sans px-2 py-1"
|
||||
disableHoverableContent
|
||||
open={isHovered || copied}
|
||||
onOpenChange={setIsHovered}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ type DateTimeProps = {
|
||||
includeTime?: boolean;
|
||||
showTimezone?: boolean;
|
||||
showTooltip?: boolean;
|
||||
hideDate?: boolean;
|
||||
previousDate?: Date | string | null; // Add optional previous date for comparison
|
||||
hour12?: boolean;
|
||||
};
|
||||
|
||||
export const DateTime = ({
|
||||
@@ -23,6 +25,7 @@ export const DateTime = ({
|
||||
includeTime = true,
|
||||
showTimezone = false,
|
||||
showTooltip = true,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
@@ -50,7 +53,8 @@ export const DateTime = ({
|
||||
timeZone ?? localTimeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime
|
||||
includeTime,
|
||||
hour12
|
||||
).replace(/\s/g, String.fromCharCode(32))}
|
||||
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
|
||||
</Fragment>
|
||||
@@ -66,7 +70,8 @@ export function formatDateTime(
|
||||
timeZone: string,
|
||||
locales: string[],
|
||||
includeSeconds: boolean,
|
||||
includeTime: boolean
|
||||
includeTime: boolean,
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
year: "numeric",
|
||||
@@ -76,6 +81,7 @@ export function formatDateTime(
|
||||
minute: includeTime ? "numeric" : undefined,
|
||||
second: includeTime && includeSeconds ? "numeric" : undefined,
|
||||
timeZone,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -122,7 +128,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
}
|
||||
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
@@ -132,8 +138,8 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
|
||||
: null;
|
||||
|
||||
// Initial formatted values
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
|
||||
|
||||
// State for the formatted time
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(
|
||||
@@ -150,10 +156,10 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: D
|
||||
// Format with appropriate function
|
||||
setFormattedDateTime(
|
||||
showDatePart
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales)
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
|
||||
);
|
||||
}, [locales, realDate, realPrevDate]);
|
||||
}, [locales, realDate, realPrevDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
@@ -168,7 +174,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
|
||||
}
|
||||
|
||||
// Format with date and time
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -178,18 +184,20 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): s
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Format time only
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
@@ -198,6 +206,8 @@ export const DateTimeAccurate = ({
|
||||
timeZone = "UTC",
|
||||
previousDate = null,
|
||||
showTooltip = true,
|
||||
hideDate = false,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
|
||||
@@ -214,11 +224,13 @@ export const DateTimeAccurate = ({
|
||||
}, []);
|
||||
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = realPrevDate
|
||||
const formattedDateTime = hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, localTimeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales);
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
|
||||
if (!showTooltip)
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
@@ -241,7 +253,7 @@ export const DateTimeAccurate = ({
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -251,26 +263,27 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
|
||||
}, [locales, realDate]);
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
|
||||
}, [locales, realDate, hour12]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
@@ -278,6 +291,7 @@ function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): s
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
|
||||
@@ -44,6 +44,24 @@ const variants = {
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
"outline/large": {
|
||||
container: "px-1 h-10 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-2 rounded text-sm",
|
||||
iconSize: "size-4 ml-1",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/medium": {
|
||||
container: "px-1 h-8 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-sm",
|
||||
iconSize: "size-4 ml-0.5",
|
||||
accessory: "pr-1",
|
||||
},
|
||||
"outline/small": {
|
||||
container: "px-1 h-6 w-full rounded border border-grid-bright hover:border-charcoal-550",
|
||||
input: "px-1 rounded text-xs",
|
||||
iconSize: "size-3 ml-0.5",
|
||||
accessory: "pr-0.5",
|
||||
},
|
||||
};
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { type ChangeEvent, useRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type InputNumberStepperProps = Omit<JSX.IntrinsicElements["input"], "min" | "max" | "step"> & {
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
round?: boolean;
|
||||
controlSize?: "base" | "large";
|
||||
};
|
||||
|
||||
export function InputNumberStepper({
|
||||
value,
|
||||
onChange,
|
||||
step = 50,
|
||||
min,
|
||||
max,
|
||||
round = true,
|
||||
controlSize = "base",
|
||||
name,
|
||||
id,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
className,
|
||||
placeholder = "Type a number",
|
||||
...props
|
||||
}: InputNumberStepperProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleStepUp = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping up
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepUp();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const handleStepDown = () => {
|
||||
if (!inputRef.current || disabled) return;
|
||||
|
||||
// If rounding is enabled, ensure we start from a rounded base before stepping
|
||||
if (round) {
|
||||
// If field is empty, treat as 0 (or min if provided) before stepping down
|
||||
if (inputRef.current.value === "") {
|
||||
inputRef.current.value = String(min ?? 0);
|
||||
} else {
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}
|
||||
inputRef.current.stepDown();
|
||||
const event = new Event("change", { bubbles: true });
|
||||
inputRef.current.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const numericValue = value === "" ? NaN : (value as number);
|
||||
const isMinDisabled = min !== undefined && !Number.isNaN(numericValue) && numericValue <= min;
|
||||
const isMaxDisabled = max !== undefined && !Number.isNaN(numericValue) && numericValue >= max;
|
||||
|
||||
function clamp(val: number): number {
|
||||
if (Number.isNaN(val)) return typeof value === "number" ? value : min ?? 0;
|
||||
let next = val;
|
||||
if (min !== undefined) next = Math.max(min, next);
|
||||
if (max !== undefined) next = Math.min(max, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function roundToStep(val: number): number {
|
||||
if (step <= 0) return val;
|
||||
const base = min ?? 0;
|
||||
const shifted = val - base;
|
||||
const quotient = shifted / step;
|
||||
const floored = Math.floor(quotient);
|
||||
const ceiled = Math.ceil(quotient);
|
||||
const down = base + floored * step;
|
||||
const up = base + ceiled * step;
|
||||
const distDown = Math.abs(val - down);
|
||||
const distUp = Math.abs(up - val);
|
||||
return distUp < distDown ? up : down;
|
||||
}
|
||||
|
||||
function commitRoundedFromInput() {
|
||||
if (!inputRef.current || disabled || readOnly) return;
|
||||
const el = inputRef.current;
|
||||
const raw = el.value;
|
||||
if (raw === "") return; // do not coerce empty to 0; keep placeholder visible
|
||||
const numeric = Number(raw);
|
||||
if (Number.isNaN(numeric)) return; // ignore non-numeric
|
||||
const rounded = clamp(roundToStep(numeric));
|
||||
if (String(rounded) === String(value)) return;
|
||||
// Update the real input's value for immediate UI feedback
|
||||
el.value = String(rounded);
|
||||
// Invoke consumer onChange with the real element as target/currentTarget
|
||||
onChange?.({
|
||||
target: el,
|
||||
currentTarget: el,
|
||||
} as unknown as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
const sizeStyles = {
|
||||
base: {
|
||||
container: "h-9",
|
||||
input: "text-sm px-3",
|
||||
button: "size-6",
|
||||
icon: "size-3.5",
|
||||
gap: "gap-1 pr-1.5",
|
||||
},
|
||||
large: {
|
||||
container: "h-11 rounded-md",
|
||||
input: "text-base px-3.5",
|
||||
button: "size-8",
|
||||
icon: "size-5",
|
||||
gap: "gap-[0.3125rem] pr-[0.3125rem]",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const size = sizeStyles[controlSize];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded border border-charcoal-600 bg-tertiary transition hover:border-charcoal-550/80 hover:bg-charcoal-600/80",
|
||||
size.container,
|
||||
"has-[:focus-visible]:outline has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0 has-[:focus-visible]:outline-text-link",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
// Allow empty string to pass through so user can clear the field
|
||||
if (e.currentTarget.value === "") {
|
||||
// reflect emptiness in the input and notify consumer as empty
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
onChange?.({
|
||||
target: e.currentTarget,
|
||||
currentTarget: e.currentTarget,
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
return;
|
||||
}
|
||||
onChange?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// If blur is caused by clicking our step buttons, we prevent pointerdown
|
||||
// so blur shouldn't fire. This is for safety in case of keyboard focus move.
|
||||
if (round) commitRoundedFromInput();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && round) {
|
||||
e.preventDefault();
|
||||
commitRoundedFromInput();
|
||||
}
|
||||
}}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground h-full grow border-0 bg-transparent text-left text-text-bright outline-none ring-0 focus:border-0 focus:outline-none focus:ring-0 disabled:cursor-not-allowed",
|
||||
size.input,
|
||||
// Hide number input arrows
|
||||
"[type=number]:border-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<div className={cn("flex items-center", size.gap)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepDown}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMinDisabled}
|
||||
aria-label={`Decrease by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-error/30 bg-error/20 transition",
|
||||
size.button,
|
||||
"hover:border-error/50 hover:bg-error/30",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<MinusIcon className={cn("text-error", size.icon)} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStepUp}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
disabled={disabled || isMaxDisabled}
|
||||
aria-label={`Increase by ${step}`}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded border border-success/30 bg-success/10 transition",
|
||||
size.button,
|
||||
"hover:border-success/40 hover:bg-success/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-link"
|
||||
)}
|
||||
>
|
||||
<PlusIcon className={cn("text-success", size.icon)} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,10 @@ const paragraphVariants = {
|
||||
text: "font-sans text-sm font-normal text-text-bright",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"small/dimmed": {
|
||||
text: "font-sans text-sm font-normal text-text-dimmed",
|
||||
spacing: "mb-2",
|
||||
},
|
||||
"extra-small": {
|
||||
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
@@ -25,6 +29,14 @@ const paragraphVariants = {
|
||||
text: "font-sans text-xs font-normal text-text-bright",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/dimmed": {
|
||||
text: "font-sans text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/dimmed/mono": {
|
||||
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
},
|
||||
"extra-small/mono": {
|
||||
text: "font-mono text-xs font-normal text-text-dimmed",
|
||||
spacing: "mb-1.5",
|
||||
|
||||
@@ -5,9 +5,10 @@ import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { Link } from "@remix-run/react";
|
||||
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
|
||||
import { type ButtonContentPropsType, Button, ButtonContent } from "./Buttons";
|
||||
import { Paragraph, type ParagraphVariant } from "./Paragraph";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { type RenderIcon } from "./Icon";
|
||||
@@ -52,42 +53,78 @@ function PopoverSectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverMenuItem({
|
||||
to,
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
className,
|
||||
}: {
|
||||
to: string;
|
||||
icon?: RenderIcon;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<LinkButton
|
||||
to={to}
|
||||
variant={variant.variant}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={leadingIconClassName}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
TrailingIcon={isSelected ? CheckIcon : undefined}
|
||||
className={cn(
|
||||
const PopoverMenuItem = React.forwardRef<
|
||||
HTMLButtonElement | HTMLAnchorElement,
|
||||
{
|
||||
to?: string;
|
||||
icon?: RenderIcon;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
leadingIconClassName?: string;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler;
|
||||
disabled?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
to,
|
||||
icon,
|
||||
title,
|
||||
isSelected,
|
||||
variant = { variant: "small-menu-item" },
|
||||
leadingIconClassName,
|
||||
className,
|
||||
onClick,
|
||||
disabled,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const contentProps = {
|
||||
variant: variant.variant,
|
||||
LeadingIcon: icon,
|
||||
leadingIconClassName,
|
||||
fullWidth: true,
|
||||
textAlignLeft: true,
|
||||
TrailingIcon: isSelected ? CheckIcon : undefined,
|
||||
className: cn(
|
||||
"group-hover:bg-charcoal-700",
|
||||
isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
),
|
||||
} as const;
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick as any}
|
||||
>
|
||||
<ButtonContent {...contentProps}>{title}</ButtonContent>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"group/button outline-none focus-custom",
|
||||
contentProps.fullWidth ? "w-full" : ""
|
||||
)}
|
||||
>
|
||||
<ButtonContent {...contentProps}>{title}</ButtonContent>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
PopoverMenuItem.displayName = "PopoverMenuItem";
|
||||
|
||||
function PopoverCustomTrigger({
|
||||
isOpen,
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type Variants = "underline" | "pipe-divider" | "segmented";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
label: string;
|
||||
@@ -12,13 +14,14 @@ export type TabsProps = {
|
||||
}[];
|
||||
className?: string;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
export function Tabs({ tabs, className, layoutId, variant = "underline" }: TabsProps) {
|
||||
return (
|
||||
<TabContainer className={className}>
|
||||
<TabContainer className={className} variant={variant}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId}>
|
||||
<TabLink key={index} to={tab.to} layoutId={layoutId} variant={variant}>
|
||||
{tab.label}
|
||||
</TabLink>
|
||||
))}
|
||||
@@ -26,23 +29,107 @@ export function Tabs({ tabs, className, layoutId }: TabsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContainer({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn(`flex flex-row gap-x-6 border-b border-grid-bright`, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export function TabContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "underline",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-10 items-center rounded bg-charcoal-700/50 p-1", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "underline") {
|
||||
return (
|
||||
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(`flex`, className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabLink({
|
||||
to,
|
||||
children,
|
||||
layoutId,
|
||||
variant = "underline",
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
}) {
|
||||
if (variant === "segmented") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10 flex h-full w-full items-center justify-center px-3 py-[0.13rem]">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] border border-charcoal-500/50 bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "pipe-divider") {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group flex flex-col items-center border-r border-charcoal-700 px-2 pt-1 focus-custom first:pl-0 last:border-none"
|
||||
end
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
active ? "text-text-link" : "text-text-dimmed transition hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -51,13 +138,19 @@ export function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive || isPending ? "text-text-bright" : "text-text-bright"
|
||||
isActive || isPending
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{isActive || isPending ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
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" />
|
||||
)}
|
||||
@@ -106,17 +199,18 @@ export function TabButton({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-bright"
|
||||
)}
|
||||
className={"text-sm transition duration-200 text-text-bright"}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
|
||||
</div>
|
||||
{isActive ? (
|
||||
<motion.div layoutId={layoutId} className="mt-1 h-0.5 w-full bg-indigo-500" />
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
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" />
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { Toaster, toast } from "sonner";
|
||||
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { type ToastMessageAction } from "~/models/message.server";
|
||||
import { type loader } from "~/root";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button, LinkButton } from "./Buttons";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -19,9 +22,22 @@ export function Toast() {
|
||||
}
|
||||
const { message, type, options } = toastMessage;
|
||||
|
||||
toast.custom((t) => <ToastUI variant={type} message={message} t={t as string} />, {
|
||||
duration: options.ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
});
|
||||
const ephemeral = options.action ? false : options.ephemeral;
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant={type}
|
||||
message={message}
|
||||
t={t as string}
|
||||
title={options.title}
|
||||
action={options.action}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
}
|
||||
);
|
||||
}, [toastMessage]);
|
||||
|
||||
return <Toaster />;
|
||||
@@ -32,11 +48,15 @@ export function ToastUI({
|
||||
message,
|
||||
t,
|
||||
toastWidth = 356, // Default width, matches what sonner provides by default
|
||||
title,
|
||||
action,
|
||||
}: {
|
||||
variant: "error" | "success";
|
||||
message: string;
|
||||
t: string;
|
||||
toastWidth?: string | number;
|
||||
title?: string;
|
||||
action?: ToastMessageAction;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -51,13 +71,19 @@ export function ToastUI({
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
|
||||
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
|
||||
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
|
||||
)}
|
||||
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
@@ -66,3 +92,49 @@ export function ToastUI({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
action,
|
||||
toastId,
|
||||
className,
|
||||
}: {
|
||||
action?: ToastMessageAction;
|
||||
toastId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
switch (action.action.type) {
|
||||
case "link": {
|
||||
return (
|
||||
<LinkButton
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
to={action.action.path}
|
||||
>
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
case "help": {
|
||||
const feedbackType = action.action.feedbackType;
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
feedbackPanel: feedbackType,
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from "~/utils/cn";
|
||||
const variantClasses = {
|
||||
basic:
|
||||
"bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50",
|
||||
dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50"
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variantClasses;
|
||||
@@ -64,6 +64,8 @@ function SimpleTooltip({
|
||||
buttonStyle,
|
||||
asChild = false,
|
||||
sideOffset,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -76,10 +78,12 @@ function SimpleTooltip({
|
||||
buttonStyle?: React.CSSProperties;
|
||||
asChild?: boolean;
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<Tooltip open={open} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
|
||||
@@ -423,6 +423,10 @@ export function useTree<TData, TFilterValue>({
|
||||
}
|
||||
case "Left":
|
||||
case "ArrowLeft": {
|
||||
if (e.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
|
||||
@@ -39,14 +39,14 @@ export function concreteStateFromInput({
|
||||
}
|
||||
const nodes = concreteStateFromPartialState(tree, state);
|
||||
|
||||
return {
|
||||
return applyFilterToState({
|
||||
tree,
|
||||
nodes,
|
||||
changes: { selectedId },
|
||||
filter,
|
||||
filteredNodes: nodes,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
visibleNodeIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
export function concreteStateFromPartialState<TData>(
|
||||
|
||||
@@ -53,6 +53,7 @@ export function DeploymentStatusIcon({
|
||||
return (
|
||||
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
|
||||
);
|
||||
case "INSTALLING":
|
||||
case "BUILDING":
|
||||
case "DEPLOYING":
|
||||
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
|
||||
@@ -78,6 +79,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-charcoal-500";
|
||||
case "INSTALLING":
|
||||
case "BUILDING":
|
||||
case "DEPLOYING":
|
||||
return "text-pending";
|
||||
@@ -98,6 +100,8 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "Queued…";
|
||||
case "INSTALLING":
|
||||
return "Installing…";
|
||||
case "BUILDING":
|
||||
return "Building…";
|
||||
case "DEPLOYING":
|
||||
@@ -127,17 +131,21 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
|
||||
// PENDING and CANCELED are not used so are ommited from the UI
|
||||
export const deploymentStatuses: WorkerDeploymentStatus[] = [
|
||||
"PENDING",
|
||||
"INSTALLING",
|
||||
"BUILDING",
|
||||
"DEPLOYING",
|
||||
"DEPLOYED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
"CANCELED",
|
||||
];
|
||||
|
||||
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "The deployment is queued and waiting to be processed.";
|
||||
case "INSTALLING":
|
||||
return "The project dependencies are being installed.";
|
||||
case "BUILDING":
|
||||
return "The code is being built and prepared for deployment.";
|
||||
case "DEPLOYING":
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function RollbackDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +58,10 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
|
||||
import { type loader as versionsLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.versions";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.environments.$envId.runs.tags";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { BulkActionTypeCombo } from "./BulkAction";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter } from "./SharedFilters";
|
||||
import { appliedSummary, FilterMenuProvider, TimeFilter, timeFilters } from "./SharedFilters";
|
||||
import { AIFilterInput } from "./AIFilterInput";
|
||||
import {
|
||||
allTaskRunStatuses,
|
||||
@@ -280,7 +280,7 @@ export function getRunFiltersFromSearchParams(
|
||||
bulkId: searchParams.get("bulkId") ?? undefined,
|
||||
tags:
|
||||
searchParams.getAll("tags").filter((v) => v.length > 0).length > 0
|
||||
? searchParams.getAll("tags").map((t) => decodeURIComponent(t))
|
||||
? searchParams.getAll("tags")
|
||||
: undefined,
|
||||
from: searchParams.get("from") ?? undefined,
|
||||
to: searchParams.get("to") ?? undefined,
|
||||
@@ -810,8 +810,8 @@ function TagsDropdown({
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const project = useProject();
|
||||
const { values, replace } = useSearchParams();
|
||||
const environment = useEnvironment();
|
||||
const { values, value, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
@@ -822,6 +822,12 @@ function TagsDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const { period, from, to } = timeFilters({
|
||||
period: value("period"),
|
||||
from: value("from"),
|
||||
to: value("to"),
|
||||
});
|
||||
|
||||
const tagValues = values("tags").filter((v) => v !== "");
|
||||
const selected = tagValues.length > 0 ? tagValues : undefined;
|
||||
|
||||
@@ -830,25 +836,34 @@ function TagsDropdown({
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("name", encodeURIComponent(searchValue));
|
||||
searchParams.set("name", searchValue);
|
||||
}
|
||||
fetcher.load(`/resources/projects/${project.slug}/runs/tags?${searchParams}`);
|
||||
}, [searchValue]);
|
||||
if (period) {
|
||||
searchParams.set("period", period);
|
||||
}
|
||||
if (from) {
|
||||
searchParams.set("from", from.getTime().toString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.set("to", to.getTime().toString());
|
||||
}
|
||||
fetcher.load(`/resources/environments/${environment.id}/runs/tags?${searchParams}`);
|
||||
}, [environment.id, searchValue, period, from?.getTime(), to?.getTime()]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items: string[] = [];
|
||||
if (searchValue === "") {
|
||||
items = selected ?? [];
|
||||
items = [...(selected ?? [])];
|
||||
}
|
||||
|
||||
if (fetcher.data === undefined) {
|
||||
return matchSorter(items, searchValue);
|
||||
}
|
||||
|
||||
items.push(...fetcher.data.tags.map((t) => t.name));
|
||||
items.push(...fetcher.data.tags);
|
||||
|
||||
return matchSorter(Array.from(new Set(items)), searchValue);
|
||||
}, [searchValue, fetcher.data]);
|
||||
}, [searchValue, fetcher.data, selected]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
|
||||
@@ -958,7 +973,7 @@ function QueuesDropdown({
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("per_page", "25");
|
||||
if (searchValue) {
|
||||
searchParams.set("query", encodeURIComponent(s));
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
@@ -1220,7 +1235,7 @@ function VersionsDropdown({
|
||||
(s) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("query", encodeURIComponent(s));
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TriggerIcon } from "~/assets/icons/TriggerIcon";
|
||||
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
|
||||
import { TraceIcon } from "~/assets/icons/TraceIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
|
||||
|
||||
type TaskIconProps = {
|
||||
name: string | undefined;
|
||||
@@ -97,6 +98,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
return <RunFunctionIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "task-hook-init":
|
||||
case "task-hook-onStart":
|
||||
case "task-hook-onStartAttempt":
|
||||
case "task-hook-onSuccess":
|
||||
case "task-hook-onWait":
|
||||
case "task-hook-onResume":
|
||||
@@ -107,6 +109,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
case "task-hook-onFailure":
|
||||
case "task-hook-catchError":
|
||||
return <FunctionIcon className={cn(className, "text-error")} />;
|
||||
case "streams":
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type NextRunListItem,
|
||||
} from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath,v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../../primitives/DateTime";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -62,9 +64,11 @@ type RunsTableProps = {
|
||||
filters: NextRunListAppliedFilters;
|
||||
showJob?: boolean;
|
||||
runs: NextRunListItem[];
|
||||
rootOnlyDefault?: boolean;
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -72,6 +76,8 @@ export function TaskRunsTable({
|
||||
hasFilters,
|
||||
filters,
|
||||
runs,
|
||||
rootOnlyDefault,
|
||||
disableAdjacentRows = false,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
@@ -81,6 +87,12 @@ export function TaskRunsTable({
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
const showCompute = isManagedCloud;
|
||||
|
||||
@@ -293,16 +305,20 @@ export function TaskRunsTable({
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (tableStateParam) {
|
||||
searchParams.set("tableState", tableStateParam);
|
||||
}
|
||||
const path = v3RunSpanPath(organization, project, run.environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
}, searchParams);
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.friendlyId)}
|
||||
onChange={(element) => {
|
||||
onChange={() => {
|
||||
toggle(run.friendlyId);
|
||||
}}
|
||||
ref={(r) => {
|
||||
@@ -565,6 +581,8 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
|
||||
|
||||
const { tasks, from, to, ...otherFilters } = filters;
|
||||
const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null;
|
||||
const testPath = singleTaskFromFilters ? v3TestTaskPath(organization, project, environment, {taskIdentifier: singleTaskFromFilters}) : v3TestPath(organization, project, environment);
|
||||
|
||||
if (
|
||||
filters.tasks.length === 1 &&
|
||||
@@ -579,7 +597,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
</Paragraph>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
@@ -620,7 +638,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={testPath}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
|
||||
@@ -330,7 +330,7 @@ function TagsDropdown({
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (searchValue) {
|
||||
searchParams.set("name", encodeURIComponent(searchValue));
|
||||
searchParams.set("name", searchValue);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}`
|
||||
|
||||
@@ -240,6 +240,7 @@ import { logger } from "./services/logger.server";
|
||||
import { Prisma } from "./db.server";
|
||||
import { registerRunEngineEventBusHandlers } from "./v3/runEngineHandlers.server";
|
||||
import { remoteBuildsEnabled } from "./v3/remoteImageBuilder.server";
|
||||
import { resourceMonitor } from "./services/resourceMonitor.server";
|
||||
|
||||
if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
|
||||
eventLoopMonitor.enable();
|
||||
@@ -250,3 +251,7 @@ if (remoteBuildsEnabled()) {
|
||||
} else {
|
||||
console.log("🏗️ Local builds enabled");
|
||||
}
|
||||
|
||||
if (env.RESOURCE_MONITOR_ENABLED === "1") {
|
||||
resourceMonitor.startMonitoring(1000);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,27 @@ const GithubAppEnvSchema = z.preprocess(
|
||||
])
|
||||
);
|
||||
|
||||
// eventually we can make all S2 env vars required once the S2 OSS version is out
|
||||
const S2EnvSchema = z.preprocess(
|
||||
(val) => {
|
||||
const obj = val as any;
|
||||
if (!obj || !obj.S2_ENABLED) {
|
||||
return { ...obj, S2_ENABLED: "0" };
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
z.discriminatedUnion("S2_ENABLED", [
|
||||
z.object({
|
||||
S2_ENABLED: z.literal("1"),
|
||||
S2_ACCESS_TOKEN: z.string(),
|
||||
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
S2_ENABLED: z.literal("0"),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const EnvironmentSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.union([z.literal("development"), z.literal("production"), z.literal("test")]),
|
||||
@@ -59,6 +80,7 @@ const EnvironmentSchema = z
|
||||
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
API_ORIGIN: z.string().optional(),
|
||||
STREAM_ORIGIN: z.string().optional(),
|
||||
@@ -72,6 +94,8 @@ const EnvironmentSchema = z
|
||||
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
@@ -197,6 +221,7 @@ const EnvironmentSchema = z
|
||||
.string()
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS: z.coerce.number().int().default(60000), // 1 minute
|
||||
|
||||
REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce
|
||||
.number()
|
||||
@@ -322,6 +347,12 @@ const EnvironmentSchema = z
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
OBJECT_STORE_REGION: z.string().optional(),
|
||||
OBJECT_STORE_SERVICE: z.string().default("s3"),
|
||||
|
||||
ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
|
||||
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
|
||||
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
|
||||
@@ -492,6 +523,7 @@ const EnvironmentSchema = z
|
||||
CENTS_PER_RUN: z.coerce.number().default(0),
|
||||
|
||||
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
|
||||
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
|
||||
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),
|
||||
@@ -752,8 +784,8 @@ const EnvironmentSchema = z
|
||||
/** The max number of runs per API call that we'll dequeue in DEV */
|
||||
DEV_DEQUEUE_MAX_RUNS_PER_PULL: z.coerce.number().int().default(10),
|
||||
|
||||
/** The maximum concurrent local run processes executing at once in dev */
|
||||
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().default(25),
|
||||
/** The maximum concurrent local run processes executing at once in dev. This is a hard limit */
|
||||
DEV_MAX_CONCURRENT_RUNS: z.coerce.number().int().optional(),
|
||||
|
||||
/** The CLI should connect to this for dev runs */
|
||||
DEV_ENGINE_URL: z.string().default(process.env.APP_ORIGIN ?? "http://localhost:3030"),
|
||||
@@ -1028,8 +1060,9 @@ const EnvironmentSchema = z
|
||||
TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"),
|
||||
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
|
||||
|
||||
QUEUE_SSE_AUTORELOAD_INTERVAL_MS: z.coerce.number().int().default(5_000),
|
||||
QUEUE_SSE_AUTORELOAD_TIMEOUT_MS: z.coerce.number().int().default(60_000),
|
||||
DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
|
||||
BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
|
||||
QUEUES_AUTORELOAD_POLL_INTERVAL_MS: z.coerce.number().int().default(5_000),
|
||||
|
||||
SLACK_BOT_TOKEN: z.string().optional(),
|
||||
SLACK_SIGNUP_REASON_CHANNEL_ID: z.string().optional(),
|
||||
@@ -1099,6 +1132,7 @@ const EnvironmentSchema = z
|
||||
RUN_REPLICATION_INSERT_BASE_DELAY_MS: z.coerce.number().int().default(100),
|
||||
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
|
||||
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||||
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
|
||||
|
||||
// Clickhouse
|
||||
CLICKHOUSE_URL: z.string(),
|
||||
@@ -1108,6 +1142,34 @@ const EnvironmentSchema = z
|
||||
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||||
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||||
|
||||
EVENTS_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
|
||||
EVENTS_CLICKHOUSE_KEEP_ALIVE_ENABLED: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS: z.coerce.number().int().optional(),
|
||||
EVENTS_CLICKHOUSE_MAX_OPEN_CONNECTIONS: z.coerce.number().int().default(10),
|
||||
EVENTS_CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
|
||||
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
|
||||
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_BUSY_TIMEOUT_MS: z.coerce.number().int().default(5000),
|
||||
EVENTS_CLICKHOUSE_START_TIME_MAX_AGE_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 5), // 5 minutes
|
||||
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
|
||||
EVENT_REPOSITORY_DEFAULT_STORE: z
|
||||
.enum(["postgres", "clickhouse", "clickhouse_v2"])
|
||||
.default("postgres"),
|
||||
EVENT_REPOSITORY_DEBUG_LOGS_DISABLED: BoolEnv.default(false),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
|
||||
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
|
||||
|
||||
// Bootstrap
|
||||
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
|
||||
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
|
||||
@@ -1176,8 +1238,24 @@ const EnvironmentSchema = z
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
|
||||
|
||||
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
|
||||
|
||||
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60_000 * 60 * 24), // 1 day
|
||||
REALTIME_STREAMS_S2_LOG_LEVEL: z
|
||||
.enum(["log", "error", "warn", "info", "debug"])
|
||||
.default("info"),
|
||||
REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS: z.coerce.number().int().default(100),
|
||||
REALTIME_STREAMS_S2_MAX_RETRIES: z.coerce.number().int().default(10),
|
||||
REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60),
|
||||
REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000),
|
||||
})
|
||||
.and(GithubAppEnvSchema);
|
||||
.and(GithubAppEnvSchema)
|
||||
.and(S2EnvSchema);
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
export const env = EnvironmentSchema.parse(process.env);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type UseAutoRevalidateOptions = {
|
||||
interval?: number; // in milliseconds
|
||||
onFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) {
|
||||
const { interval = 5000, onFocus = true, disabled = false } = options;
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (!interval || interval <= 0 || disabled) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (revalidator.state === "loading") {
|
||||
return;
|
||||
}
|
||||
revalidator.revalidate();
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [interval, disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onFocus || disabled) return;
|
||||
|
||||
const handleFocus = () => {
|
||||
if (document.visibilityState === "visible" && revalidator.state !== "loading") {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
// Revalidate when the page becomes visible
|
||||
document.addEventListener("visibilitychange", handleFocus);
|
||||
// Revalidate when the window gains focus
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleFocus);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [onFocus, disabled]);
|
||||
|
||||
return revalidator;
|
||||
}
|
||||
@@ -33,12 +33,7 @@ export function useSearchParams() {
|
||||
const value = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
const val = search.get(param) ?? undefined;
|
||||
if (val === undefined) {
|
||||
return val;
|
||||
}
|
||||
|
||||
return decodeURIComponent(val);
|
||||
return search.get(param) ?? undefined;
|
||||
},
|
||||
[location]
|
||||
);
|
||||
@@ -46,8 +41,7 @@ export function useSearchParams() {
|
||||
const values = useCallback(
|
||||
(param: string) => {
|
||||
const search = new URLSearchParams(location.search);
|
||||
const all = search.getAll(param);
|
||||
return all.map((v) => decodeURIComponent(v));
|
||||
return search.getAll(param);
|
||||
},
|
||||
[location]
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { type Prisma, prisma } from "~/db.server";
|
||||
import { createEnvironment } from "./organization.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const tokenValueLength = 40;
|
||||
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
||||
|
||||
export async function getTeamMembersAndInvites({
|
||||
userId,
|
||||
@@ -95,14 +99,19 @@ export async function inviteMembers({
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
|
||||
const created = await prisma.orgMemberInvite.createMany({
|
||||
data: emails.map((email) => ({
|
||||
email,
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
const invites = [...new Set(emails)].map(
|
||||
(email) =>
|
||||
({
|
||||
email,
|
||||
token: tokenGenerator(),
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
} satisfies Prisma.OrgMemberInviteCreateManyInput)
|
||||
);
|
||||
|
||||
await prisma.orgMemberInvite.createMany({
|
||||
data: invites,
|
||||
});
|
||||
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
@@ -147,12 +156,19 @@ export async function getUsersInvites({ email }: { email: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
|
||||
export async function acceptInvite({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// 1. Delete the invite and get the invite details
|
||||
const invite = await tx.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
@@ -167,7 +183,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
const member = await tx.orgMember.create({
|
||||
data: {
|
||||
organizationId: invite.organizationId,
|
||||
userId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
@@ -187,7 +203,7 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
// 4. Check for other invites
|
||||
const remainingInvites = await tx.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email: invite.email,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,28 +211,29 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit
|
||||
});
|
||||
}
|
||||
|
||||
export async function declineInvite({ userId, inviteId }: { userId: string; inviteId: string }) {
|
||||
export async function declineInvite({
|
||||
user,
|
||||
inviteId,
|
||||
}: {
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
//1. delete invite
|
||||
const declinedInvite = await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: inviteId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
//2. get email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
//3. check for other invites
|
||||
//2. check for other invites
|
||||
const remainingInvites = await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email: user!.email,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,10 +241,11 @@ export async function declineInvite({ userId, inviteId }: { userId: string; invi
|
||||
});
|
||||
}
|
||||
|
||||
export async function resendInvite({ inviteId }: { inviteId: string }) {
|
||||
export async function resendInvite({ inviteId, userId }: { inviteId: string; userId: string }) {
|
||||
return await prisma.orgMemberInvite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
inviterId: userId,
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(),
|
||||
@@ -241,26 +259,27 @@ export async function resendInvite({ inviteId }: { inviteId: string }) {
|
||||
|
||||
export async function revokeInvite({
|
||||
userId,
|
||||
slug,
|
||||
orgSlug,
|
||||
inviteId,
|
||||
}: {
|
||||
userId: string;
|
||||
slug: string;
|
||||
orgSlug: string;
|
||||
inviteId: string;
|
||||
}) {
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error("User does not have access to this organization");
|
||||
}
|
||||
const invite = await prisma.orgMemberInvite.delete({
|
||||
const invite = await prisma.orgMemberInvite.findFirst({
|
||||
where: {
|
||||
id: inviteId,
|
||||
organizationId: org.id,
|
||||
organization: {
|
||||
slug: orgSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
organization: true,
|
||||
},
|
||||
@@ -270,5 +289,11 @@ export async function revokeInvite({
|
||||
throw new Error("Invite not found");
|
||||
}
|
||||
|
||||
await prisma.orgMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { email: invite.email, organization: invite.organization };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { json, createCookieSessionStorage, type Session } from "@remix-run/node";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import { ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { env } from "~/env.server";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
|
||||
export type ToastMessage = {
|
||||
message: string;
|
||||
@@ -9,9 +10,26 @@ export type ToastMessage = {
|
||||
options: Required<ToastMessageOptions>;
|
||||
};
|
||||
|
||||
export type ToastMessageAction = {
|
||||
label: string;
|
||||
variant?: ButtonVariant;
|
||||
action:
|
||||
| {
|
||||
type: "link";
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "help";
|
||||
feedbackType: FeedbackType;
|
||||
};
|
||||
};
|
||||
|
||||
export type ToastMessageOptions = {
|
||||
title?: string;
|
||||
/** Ephemeral means it disappears after a delay, defaults to true */
|
||||
ephemeral?: boolean;
|
||||
/** This display a button and make it not ephemeral, unless ephemeral is explicitlyset to false */
|
||||
action?: ToastMessageAction;
|
||||
};
|
||||
|
||||
const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
@@ -36,6 +54,7 @@ export function setSuccessMessage(
|
||||
message,
|
||||
type: "success",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
@@ -46,6 +65,7 @@ export function setErrorMessage(session: Session, message: string, options?: Toa
|
||||
message,
|
||||
type: "error",
|
||||
options: {
|
||||
...options,
|
||||
ephemeral: options?.ephemeral ?? true,
|
||||
},
|
||||
} as ToastMessage);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { featuresForUrl } from "~/features.server";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server";
|
||||
|
||||
import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server";
|
||||
export type { Organization };
|
||||
|
||||
const nanoid = customAlphabet("1234567890abcdef", 4);
|
||||
@@ -66,7 +66,7 @@ export async function createOrganization(
|
||||
role: "ADMIN",
|
||||
},
|
||||
},
|
||||
v3Enabled: !features.isManagedCloud,
|
||||
v3Enabled: true,
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
@@ -96,6 +96,8 @@ export async function createEnvironment({
|
||||
const pkApiKey = createPkApiKeyForEnv(type);
|
||||
const shortcode = createShortcode().join("-");
|
||||
|
||||
const limit = await getDefaultEnvironmentConcurrencyLimit(organization.id, type);
|
||||
|
||||
return await prismaClient.runtimeEnvironment.create({
|
||||
data: {
|
||||
slug,
|
||||
@@ -103,7 +105,7 @@ export async function createEnvironment({
|
||||
pkApiKey,
|
||||
shortcode,
|
||||
autoEnableInternalSources: type !== "DEVELOPMENT",
|
||||
maximumConcurrencyLimit: organization.maximumConcurrencyLimit / 3,
|
||||
maximumConcurrencyLimit: limit,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
|
||||
@@ -16,12 +16,26 @@ type Options = {
|
||||
version: "v2" | "v3";
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ExceededProjectLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
const organization = await prisma.organization.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
v3Enabled: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
@@ -40,6 +54,19 @@ export async function createProject(
|
||||
}
|
||||
}
|
||||
|
||||
const projectCount = await prisma.project.count({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (projectCount >= organization.maximumProjectCount) {
|
||||
throw new ExceededProjectLimitError(
|
||||
`This organization has reached the maximum number of projects (${organization.maximumProjectCount}).`
|
||||
);
|
||||
}
|
||||
|
||||
//ensure the slug is globally unique
|
||||
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
|
||||
const projectWithSameSlug = await prisma.project.findFirst({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
authenticationMethod: "MAGIC_LINK";
|
||||
email: string;
|
||||
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
|
||||
type FindOrCreateGoogle = {
|
||||
authenticationMethod: "GOOGLE";
|
||||
email: User["email"];
|
||||
authenticationProfile: GoogleProfile;
|
||||
authenticationExtraParams: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
|
||||
|
||||
type LoggedInUser = {
|
||||
user: User;
|
||||
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
|
||||
case "MAGIC_LINK": {
|
||||
return findOrCreateMagicLinkUser(input);
|
||||
}
|
||||
case "GOOGLE": {
|
||||
return findOrCreateGoogleUser(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export async function findOrCreateGoogleUser({
|
||||
email,
|
||||
authenticationProfile,
|
||||
authenticationExtraParams,
|
||||
}: FindOrCreateGoogle): Promise<LoggedInUser> {
|
||||
assertEmailAllowed(email);
|
||||
|
||||
const name = authenticationProfile._json.name;
|
||||
let avatarUrl: string | undefined = undefined;
|
||||
if (authenticationProfile.photos[0]) {
|
||||
avatarUrl = authenticationProfile.photos[0].value;
|
||||
}
|
||||
const displayName = authenticationProfile.displayName;
|
||||
const authProfile = authenticationProfile
|
||||
? (authenticationProfile as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
const authExtraParams = authenticationExtraParams
|
||||
? (authenticationExtraParams as unknown as Prisma.JsonObject)
|
||||
: undefined;
|
||||
|
||||
const authIdentifier = `google:${authenticationProfile.id}`;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
const existingEmailUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEmailUser && !existingUser) {
|
||||
// Link existing email account to Google auth, preserving original authenticationMethod
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
data: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
avatarUrl,
|
||||
authIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (existingEmailUser && existingUser) {
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
existingEmailUserId: existingEmailUser.id,
|
||||
existingAuthUserId: existingUser.id,
|
||||
authIdentifier,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
user: existingUser,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Same user: update all profile fields
|
||||
const user = await prisma.user.update({
|
||||
where: {
|
||||
id: existingUser.id,
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: false,
|
||||
};
|
||||
}
|
||||
|
||||
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
|
||||
// It's not possible that the email is already taken by a different user because that would have been handled
|
||||
// by one of the if statements above.
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
authIdentifier,
|
||||
},
|
||||
update: {
|
||||
email,
|
||||
displayName,
|
||||
name,
|
||||
avatarUrl,
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
},
|
||||
create: {
|
||||
authenticationProfile: authProfile,
|
||||
authenticationExtraParams: authExtraParams,
|
||||
name,
|
||||
avatarUrl,
|
||||
displayName,
|
||||
authIdentifier,
|
||||
email,
|
||||
authenticationMethod: "GOOGLE",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isNewUser: !existingUser,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,7 @@ export class ApiRetrieveRunPresenter {
|
||||
},
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
traceId: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
output: true,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
||||
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Organization } from "~/models/organization.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
@@ -11,6 +12,24 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { processGitMetadata } from "./BranchesPresenter.server";
|
||||
import { S2 } from "@s2-dev/streamstore";
|
||||
import { env } from "~/env.server";
|
||||
import { createRedisClient } from "~/redis.server";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
|
||||
|
||||
const s2TokenRedis = createRedisClient("s2-token-cache", {
|
||||
host: env.CACHE_REDIS_HOST,
|
||||
port: env.CACHE_REDIS_PORT,
|
||||
username: env.CACHE_REDIS_USERNAME,
|
||||
password: env.CACHE_REDIS_PASSWORD,
|
||||
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
|
||||
|
||||
export type ErrorData = {
|
||||
name: string;
|
||||
@@ -43,6 +62,7 @@ export class DeploymentPresenter {
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
externalRef: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
@@ -103,6 +123,9 @@ export class DeploymentPresenter {
|
||||
deployedAt: true,
|
||||
createdAt: true,
|
||||
startedAt: true,
|
||||
installedAt: true,
|
||||
canceledAt: true,
|
||||
canceledReason: true,
|
||||
git: true,
|
||||
promotions: {
|
||||
select: {
|
||||
@@ -132,14 +155,40 @@ export class DeploymentPresenter {
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gitMetadata = processGitMetadata(deployment.git);
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
const buildServerMetadata = deployment.buildServerMetadata
|
||||
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
||||
: undefined;
|
||||
|
||||
let eventStream = undefined;
|
||||
if (
|
||||
env.S2_ENABLED === "1" &&
|
||||
(buildServerMetadata || gitMetadata?.source === "trigger_github_app")
|
||||
) {
|
||||
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
||||
|
||||
if (error) {
|
||||
logger.error("Failed getting S2 access token", { error });
|
||||
} else {
|
||||
eventStream = {
|
||||
s2: {
|
||||
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
accessToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventStream,
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
shortCode: deployment.shortCode,
|
||||
@@ -147,8 +196,11 @@ export class DeploymentPresenter {
|
||||
status: deployment.status,
|
||||
createdAt: deployment.createdAt,
|
||||
startedAt: deployment.startedAt,
|
||||
installedAt: deployment.installedAt,
|
||||
builtAt: deployment.builtAt,
|
||||
deployedAt: deployment.deployedAt,
|
||||
canceledAt: deployment.canceledAt,
|
||||
canceledReason: deployment.canceledReason,
|
||||
tasks: deployment.worker?.tasks,
|
||||
label: deployment.promotions?.[0]?.label,
|
||||
environment: {
|
||||
@@ -172,11 +224,46 @@ export class DeploymentPresenter {
|
||||
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
|
||||
isBuilt: !!deployment.builtAt,
|
||||
type: deployment.type,
|
||||
git: processGitMetadata(deployment.git),
|
||||
git: gitMetadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getS2AccessToken(projectRef: string): Promise<string> {
|
||||
if (env.S2_ENABLED !== "1" || !s2) {
|
||||
throw new Error("Failed getting S2 access token: S2 is not enabled");
|
||||
}
|
||||
|
||||
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
|
||||
const cachedToken = await s2TokenRedis.get(redisKey);
|
||||
|
||||
if (cachedToken) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
const { access_token: accessToken } = await s2.accessTokens.issue({
|
||||
id: `${projectRef}-${new Date().getTime()}`,
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
|
||||
scope: {
|
||||
ops: ["read"],
|
||||
basins: {
|
||||
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
||||
},
|
||||
streams: {
|
||||
prefix: `projects/${projectRef}/deployments/`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await s2TokenRedis.setex(
|
||||
redisKey,
|
||||
59 * 60, // slightly shorter than the token validity period
|
||||
accessToken
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
if (!errorData) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { err, fromPromise, ok, ResultAsync } from "neverthrow";
|
||||
import { env } from "~/env.server";
|
||||
import { BranchTrackingConfigSchema } from "~/v3/github";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type GitHubSettingsOptions = {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export class GitHubSettingsPresenter extends BasePresenter {
|
||||
public call({ projectId, organizationId }: GitHubSettingsOptions) {
|
||||
const githubAppEnabled = env.GITHUB_APP_ENABLED === "1";
|
||||
|
||||
if (!githubAppEnabled) {
|
||||
return ok({
|
||||
enabled: false,
|
||||
connectedRepository: undefined,
|
||||
installations: undefined,
|
||||
isPreviewEnvironmentEnabled: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const findConnectedGithubRepository = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).connectedGithubRepository.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
repository: {
|
||||
installation: {
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
branchTracking: true,
|
||||
previewDeploymentsEnabled: true,
|
||||
createdAt: true,
|
||||
repository: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((connectedGithubRepository) => {
|
||||
if (!connectedGithubRepository) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchTrackingOrFailure = BranchTrackingConfigSchema.safeParse(
|
||||
connectedGithubRepository.branchTracking
|
||||
);
|
||||
const branchTracking = branchTrackingOrFailure.success
|
||||
? branchTrackingOrFailure.data
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...connectedGithubRepository,
|
||||
branchTracking,
|
||||
};
|
||||
});
|
||||
|
||||
const listGithubAppInstallations = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).githubAppInstallation.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
deletedAt: null,
|
||||
suspendedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accountHandle: true,
|
||||
targetType: true,
|
||||
appInstallationId: true,
|
||||
repositories: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
fullName: true,
|
||||
htmlUrl: true,
|
||||
private: true,
|
||||
},
|
||||
take: 200,
|
||||
},
|
||||
},
|
||||
take: 20,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
const isPreviewEnvironmentEnabled = () =>
|
||||
fromPromise(
|
||||
(this._replica as PrismaClient).runtimeEnvironment.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: "preview",
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map((previewEnvironment) => previewEnvironment !== null);
|
||||
|
||||
return ResultAsync.combine([
|
||||
isPreviewEnvironmentEnabled(),
|
||||
findConnectedGithubRepository(),
|
||||
listGithubAppInstallations(),
|
||||
]).map(([isPreviewEnvironmentEnabled, connectedGithubRepository, githubAppInstallations]) => ({
|
||||
enabled: true,
|
||||
connectedRepository: connectedGithubRepository,
|
||||
installations: githubAppInstallations,
|
||||
isPreviewEnvironmentEnabled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getDefaultEnvironmentLimitFromPlan,
|
||||
getPlans,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type ConcurrencyResult = {
|
||||
canAddConcurrency: boolean;
|
||||
environments: EnvironmentWithConcurrency[];
|
||||
extraConcurrency: number;
|
||||
extraAllocatedConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EnvironmentWithConcurrency = {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
isBranchableEnvironment: boolean;
|
||||
branchName: string | null;
|
||||
parentEnvironmentId: string | null;
|
||||
maximumConcurrencyLimit: number;
|
||||
planConcurrencyLimit: number;
|
||||
};
|
||||
|
||||
export class ManageConcurrencyPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
}): Promise<ConcurrencyResult> {
|
||||
// Get plan
|
||||
const currentPlan = await getCurrentPlan(organizationId);
|
||||
if (!currentPlan) {
|
||||
throw new Error("No plan found");
|
||||
}
|
||||
|
||||
const canAddConcurrency =
|
||||
currentPlan.v3Subscription.plan?.limits.concurrentRuns.canExceed === true;
|
||||
|
||||
const environments = await this._replica.runtimeEnvironment.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
type: true,
|
||||
branchName: true,
|
||||
parentEnvironmentId: true,
|
||||
isBranchableEnvironment: true,
|
||||
maximumConcurrencyLimit: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
organizationId,
|
||||
archivedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const extraConcurrency = currentPlan?.v3Subscription.addOns?.concurrentRuns?.purchased ?? 0;
|
||||
|
||||
// Go through all environments and add up extra concurrency above their allowed allocation
|
||||
let extraAllocatedConcurrency = 0;
|
||||
const projectEnvironments: EnvironmentWithConcurrency[] = [];
|
||||
for (const environment of environments) {
|
||||
// Don't count parent environments
|
||||
if (environment.isBranchableEnvironment) continue;
|
||||
|
||||
// Don't count deleted projects
|
||||
if (environment.project.deletedAt) continue;
|
||||
|
||||
const limit = currentPlan
|
||||
? getDefaultEnvironmentLimitFromPlan(environment.type, currentPlan)
|
||||
: 0;
|
||||
if (!limit) continue;
|
||||
|
||||
// If it's not DEV and they've increased, track that
|
||||
// You can't spend money to increase DEV concurrency
|
||||
if (environment.type !== "DEVELOPMENT" && environment.maximumConcurrencyLimit > limit) {
|
||||
extraAllocatedConcurrency += environment.maximumConcurrencyLimit - limit;
|
||||
}
|
||||
|
||||
// We only want to show this project's environments
|
||||
if (environment.projectId === projectId) {
|
||||
if (environment.type === "DEVELOPMENT" && environment.orgMember?.userId !== userId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
projectEnvironments.push({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
isBranchableEnvironment: environment.isBranchableEnvironment,
|
||||
branchName: environment.branchName,
|
||||
parentEnvironmentId: environment.parentEnvironmentId,
|
||||
maximumConcurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
planConcurrencyLimit: limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const extraAllocated = Math.min(extraConcurrency, extraAllocatedConcurrency);
|
||||
|
||||
const plans = await getPlans();
|
||||
if (!plans) {
|
||||
throw new Error("Couldn't retrieve add on pricing");
|
||||
}
|
||||
|
||||
return {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency: extraAllocated,
|
||||
extraUnallocatedConcurrency: extraConcurrency - extraAllocated,
|
||||
maxQuota: currentPlan.v3Subscription.addOns?.concurrentRuns?.quota ?? 0,
|
||||
environments: sortEnvironments(projectEnvironments, [
|
||||
"PRODUCTION",
|
||||
"STAGING",
|
||||
"PREVIEW",
|
||||
"DEVELOPMENT",
|
||||
]),
|
||||
concurrencyPricing: plans.addOnPricing.concurrency,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,9 @@ export class QueueListPresenter extends BasePresenter {
|
||||
name: true,
|
||||
orderableName: true,
|
||||
concurrencyLimit: true,
|
||||
concurrencyLimitBase: true,
|
||||
concurrencyLimitOverriddenAt: true,
|
||||
concurrencyLimitOverriddenBy: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
@@ -135,6 +138,17 @@ export class QueueListPresenter extends BasePresenter {
|
||||
),
|
||||
]);
|
||||
|
||||
// Manually "join" the overridden users because there is no way to implement the relationship
|
||||
// in prisma without adding a foreign key constraint
|
||||
const overriddenByIds = queues.map((q) => q.concurrencyLimitOverriddenBy).filter(Boolean);
|
||||
const overriddenByUsers = await this._replica.user.findMany({
|
||||
where: {
|
||||
id: { in: overriddenByIds },
|
||||
},
|
||||
});
|
||||
|
||||
const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u]));
|
||||
|
||||
// Transform queues to include running and queued counts
|
||||
return queues.map((queue) =>
|
||||
toQueueItem({
|
||||
@@ -144,6 +158,11 @@ export class QueueListPresenter extends BasePresenter {
|
||||
running: results[1][queue.name] ?? 0,
|
||||
queued: results[0][queue.name] ?? 0,
|
||||
concurrencyLimit: queue.concurrencyLimit ?? null,
|
||||
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
|
||||
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
|
||||
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy
|
||||
? overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null
|
||||
: null,
|
||||
paused: queue.paused,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { type TaskQueueType } from "@trigger.dev/database";
|
||||
import { TaskQueue, User, type TaskQueueType } from "@trigger.dev/database";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
|
||||
import { type Prettify, type QueueItem, type RetrieveQueueParam } from "@trigger.dev/core/v3";
|
||||
import { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
|
||||
export type FoundQueue = Prettify<
|
||||
Omit<TaskQueue, "concurrencyLimitOverriddenBy"> & {
|
||||
concurrencyLimitOverriddenBy?: User | null;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Shared queue lookup logic used by both QueueRetrievePresenter and PauseQueueService
|
||||
*/
|
||||
@@ -16,22 +22,50 @@ export async function getQueue(
|
||||
queue: RetrieveQueueParam
|
||||
) {
|
||||
if (typeof queue === "string") {
|
||||
return prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
friendlyId: queue,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
});
|
||||
return joinQueueWithUser(
|
||||
prismaClient,
|
||||
await prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
friendlyId: queue,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const queueName =
|
||||
queue.type === "task" ? `task/${queue.name.replace(/^task\//, "")}` : queue.name;
|
||||
return prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
name: queueName,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
return joinQueueWithUser(
|
||||
prismaClient,
|
||||
await prismaClient.taskQueue.findFirst({
|
||||
where: {
|
||||
name: queueName,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function joinQueueWithUser(
|
||||
prismaClient: PrismaClientOrTransaction,
|
||||
queue?: TaskQueue | null
|
||||
): Promise<FoundQueue | undefined> {
|
||||
if (!queue) return undefined;
|
||||
if (!queue.concurrencyLimitOverriddenBy) {
|
||||
return {
|
||||
...queue,
|
||||
concurrencyLimitOverriddenBy: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await prismaClient.user.findFirst({
|
||||
where: { id: queue.concurrencyLimitOverriddenBy },
|
||||
});
|
||||
|
||||
return {
|
||||
...queue,
|
||||
concurrencyLimitOverriddenBy: user,
|
||||
};
|
||||
}
|
||||
|
||||
export class QueueRetrievePresenter extends BasePresenter {
|
||||
@@ -75,6 +109,9 @@ export class QueueRetrievePresenter extends BasePresenter {
|
||||
running: results[1]?.[queue.name] ?? 0,
|
||||
queued: results[0]?.[queue.name] ?? 0,
|
||||
concurrencyLimit: queue.concurrencyLimit ?? null,
|
||||
concurrencyLimitBase: queue.concurrencyLimitBase ?? null,
|
||||
concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null,
|
||||
concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null,
|
||||
paused: queue.paused,
|
||||
}),
|
||||
};
|
||||
@@ -104,6 +141,9 @@ export function toQueueItem(data: {
|
||||
running: number;
|
||||
queued: number;
|
||||
concurrencyLimit: number | null;
|
||||
concurrencyLimitBase: number | null;
|
||||
concurrencyLimitOverriddenAt: Date | null;
|
||||
concurrencyLimitOverriddenBy: User | null;
|
||||
paused: boolean;
|
||||
}): QueueItem & { releaseConcurrencyOnWaitpoint: boolean } {
|
||||
return {
|
||||
@@ -113,9 +153,22 @@ export function toQueueItem(data: {
|
||||
type: queueTypeFromType(data.type),
|
||||
running: data.running,
|
||||
queued: data.queued,
|
||||
concurrencyLimit: data.concurrencyLimit,
|
||||
paused: data.paused,
|
||||
concurrencyLimit: data.concurrencyLimit,
|
||||
concurrency: {
|
||||
current: data.concurrencyLimit,
|
||||
base: data.concurrencyLimitBase,
|
||||
override: data.concurrencyLimitOverriddenAt ? data.concurrencyLimit : null,
|
||||
overriddenBy: toQueueConcurrencyOverriddenBy(data.concurrencyLimitOverriddenBy),
|
||||
overriddenAt: data.concurrencyLimitOverriddenAt,
|
||||
},
|
||||
// TODO: This needs to be removed but keeping this here for now to avoid breaking existing clients
|
||||
releaseConcurrencyOnWaitpoint: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toQueueConcurrencyOverriddenBy(user: User | null) {
|
||||
if (!user) return null;
|
||||
|
||||
return user.displayName ?? user.name ?? null;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/Tr
|
||||
import { prisma, type PrismaClient } from "~/db.server";
|
||||
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
|
||||
export type Run = Result["run"];
|
||||
@@ -28,7 +30,6 @@ export class RunPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
environmentSlug,
|
||||
runFriendlyId,
|
||||
showDeletedLogs,
|
||||
@@ -36,7 +37,6 @@ export class RunPresenter {
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
environmentSlug: string;
|
||||
runFriendlyId: string;
|
||||
showDeletedLogs: boolean;
|
||||
@@ -47,9 +47,11 @@ export class RunPresenter {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
taskEventStore: true,
|
||||
taskIdentifier: true,
|
||||
number: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
parentSpanId: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
startedAt: true,
|
||||
@@ -93,6 +95,13 @@ export class RunPresenter {
|
||||
friendlyId: runFriendlyId,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -132,21 +141,56 @@ export class RunPresenter {
|
||||
return {
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
|
||||
};
|
||||
}
|
||||
|
||||
const eventRepository = resolveEventRepositoryForStore(run.taskEventStore);
|
||||
|
||||
// get the events
|
||||
const traceSummary = await eventRepository.getTraceSummary(
|
||||
let traceSummary = await eventRepository.getTraceSummary(
|
||||
getTaskEventStoreTableForRun(run),
|
||||
run.runtimeEnvironment.id,
|
||||
run.traceId,
|
||||
run.rootTaskRun?.createdAt ?? run.createdAt,
|
||||
run.completedAt ?? undefined,
|
||||
{ includeDebugLogs: showDebug }
|
||||
);
|
||||
|
||||
if (!traceSummary) {
|
||||
return {
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
const spanSummary: SpanSummary = {
|
||||
id: run.spanId,
|
||||
parentId: run.parentSpanId ?? undefined,
|
||||
runId: run.friendlyId,
|
||||
data: {
|
||||
message: run.taskIdentifier,
|
||||
style: { icon: "task", variant: "primary" },
|
||||
events: [],
|
||||
startTime: run.createdAt,
|
||||
duration: 0,
|
||||
isError:
|
||||
run.status === "COMPLETED_WITH_ERRORS" ||
|
||||
run.status === "CRASHED" ||
|
||||
run.status === "EXPIRED" ||
|
||||
run.status === "SYSTEM_FAILURE" ||
|
||||
run.status === "TIMED_OUT",
|
||||
isPartial:
|
||||
run.status === "DELAYED" ||
|
||||
run.status === "PENDING" ||
|
||||
run.status === "PAUSED" ||
|
||||
run.status === "RETRYING_AFTER_FAILURE" ||
|
||||
run.status === "DEQUEUED" ||
|
||||
run.status === "EXECUTING" ||
|
||||
run.status === "WAITING_TO_RESUME",
|
||||
isCancelled: run.status === "CANCELED",
|
||||
isDebug: false,
|
||||
level: "TRACE",
|
||||
},
|
||||
};
|
||||
|
||||
traceSummary = {
|
||||
rootSpan: spanSummary,
|
||||
spans: [spanSummary],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +259,9 @@ export class RunPresenter {
|
||||
queuedDuration: run.startedAt
|
||||
? millisecondsToNanoseconds(run.startedAt.getTime() - run.createdAt.getTime())
|
||||
: undefined,
|
||||
overridesBySpanId: traceSummary.overridesBySpanId,
|
||||
},
|
||||
maximumLiveReloadingSetting: eventRepository.maximumLiveReloadingSetting,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eventStream } from "remix-utils/sse/server";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { throttle } from "~/utils/throttle";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { tracePubSub } from "~/v3/services/tracePubSub.server";
|
||||
|
||||
const pingInterval = 1000;
|
||||
|
||||
@@ -41,7 +41,7 @@ export class RunStreamPresenter {
|
||||
|
||||
let pinger: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const { unsubscribe, eventEmitter } = await eventRepository.subscribeToTrace(run.traceId);
|
||||
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
|
||||
|
||||
return eventStream(request.signal, (send, close) => {
|
||||
const safeSend = (args: { event?: string; data: string }) => {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
export type TagListOptions = {
|
||||
userId?: string;
|
||||
organizationId: string;
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
period?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
//filters
|
||||
name?: string;
|
||||
//pagination
|
||||
@@ -17,40 +25,39 @@ export type TagListItem = TagList["tags"][number];
|
||||
|
||||
export class RunTagListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId,
|
||||
name,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
page = 1,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: TagListOptions) {
|
||||
const hasFilters = Boolean(name?.trim());
|
||||
|
||||
const tags = await this._replica.taskRunTag.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: name
|
||||
? {
|
||||
startsWith: name,
|
||||
mode: "insensitive",
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
take: pageSize + 1,
|
||||
skip: (page - 1) * pageSize,
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: clickhouseClient,
|
||||
prisma: this._replica as PrismaClient,
|
||||
});
|
||||
|
||||
const tags = await runsRepository.listTags({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
query: name,
|
||||
period,
|
||||
from: from ? from.getTime() : undefined,
|
||||
to: to ? to.getTime() : undefined,
|
||||
offset: (page - 1) * pageSize,
|
||||
limit: pageSize + 1,
|
||||
});
|
||||
|
||||
return {
|
||||
tags: tags
|
||||
.map((tag) => ({
|
||||
id: tag.friendlyId,
|
||||
name: tag.name,
|
||||
}))
|
||||
.slice(0, pageSize),
|
||||
tags: tags.tags,
|
||||
currentPage: page,
|
||||
hasMore: tags.length > pageSize,
|
||||
hasMore: tags.tags.length > pageSize,
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import {
|
||||
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { eventRepository, rehydrateAttribute } from "~/v3/eventRepository.server";
|
||||
import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server";
|
||||
import { machinePresetFromRun } from "~/v3/machinePresets.server";
|
||||
import { getTaskEventStoreTableForRun, type TaskEventStoreTable } from "~/v3/taskEventStore.server";
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { WaitpointPresenter } from "./WaitpointPresenter.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
|
||||
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
|
||||
export type Span = NonNullable<NonNullable<Result>["span"]>;
|
||||
@@ -24,14 +27,16 @@ export type SpanRun = NonNullable<NonNullable<Result>["run"]>;
|
||||
type FindRunResult = NonNullable<
|
||||
Awaited<ReturnType<InstanceType<typeof SpanPresenter>["findRun"]>>
|
||||
>;
|
||||
type GetSpanResult = NonNullable<Awaited<ReturnType<(typeof eventRepository)["getSpan"]>>>;
|
||||
type GetSpanResult = SpanDetail;
|
||||
|
||||
export class SpanPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
spanId,
|
||||
runFriendlyId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
spanId: string;
|
||||
runFriendlyId: string;
|
||||
@@ -39,6 +44,13 @@ export class SpanPresenter extends BasePresenter {
|
||||
const project = await this._replica.project.findFirst({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +69,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
where: {
|
||||
friendlyId: runFriendlyId,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,14 +79,18 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
const { traceId } = parentRun;
|
||||
|
||||
const eventRepository = resolveEventRepositoryForStore(parentRun.taskEventStore);
|
||||
|
||||
const eventStore = getTaskEventStoreTableForRun(parentRun);
|
||||
|
||||
const run = await this.getRun({
|
||||
eventStore,
|
||||
traceId,
|
||||
eventRepository,
|
||||
spanId,
|
||||
createdAt: parentRun.createdAt,
|
||||
completedAt: parentRun.completedAt,
|
||||
environmentId: parentRun.runtimeEnvironmentId,
|
||||
});
|
||||
if (run) {
|
||||
return {
|
||||
@@ -82,15 +99,15 @@ export class SpanPresenter extends BasePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
//get the run
|
||||
const span = await this.#getSpan({
|
||||
eventStore,
|
||||
traceId,
|
||||
spanId,
|
||||
traceId,
|
||||
environmentId: parentRun.runtimeEnvironmentId,
|
||||
projectId: parentRun.projectId,
|
||||
createdAt: parentRun.createdAt,
|
||||
completedAt: parentRun.completedAt,
|
||||
eventRepository,
|
||||
});
|
||||
|
||||
if (!span) {
|
||||
@@ -105,30 +122,31 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
async getRun({
|
||||
eventStore,
|
||||
environmentId,
|
||||
traceId,
|
||||
eventRepository,
|
||||
spanId,
|
||||
createdAt,
|
||||
completedAt,
|
||||
}: {
|
||||
eventStore: TaskEventStoreTable;
|
||||
environmentId: string;
|
||||
traceId: string;
|
||||
eventRepository: IEventRepository;
|
||||
spanId: string;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}) {
|
||||
const span = await eventRepository.getSpan(
|
||||
const originalRunId = await eventRepository.getSpanOriginalRunId(
|
||||
eventStore,
|
||||
environmentId,
|
||||
spanId,
|
||||
traceId,
|
||||
createdAt,
|
||||
completedAt ?? undefined
|
||||
);
|
||||
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = await this.findRun({ span, spanId });
|
||||
const run = await this.findRun({ originalRunId, spanId, environmentId });
|
||||
|
||||
if (!run) {
|
||||
return;
|
||||
@@ -251,9 +269,11 @@ export class SpanPresenter extends BasePresenter {
|
||||
engine: run.engine,
|
||||
region,
|
||||
workerQueue: run.workerQueue,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
isCached: !!span.originalRun,
|
||||
isCached: !!originalRunId,
|
||||
machinePreset: machine?.name,
|
||||
taskEventStore: run.taskEventStore,
|
||||
externalTraceId,
|
||||
};
|
||||
}
|
||||
@@ -287,7 +307,15 @@ export class SpanPresenter extends BasePresenter {
|
||||
};
|
||||
}
|
||||
|
||||
async findRun({ span, spanId }: { span: GetSpanResult; spanId: string }) {
|
||||
async findRun({
|
||||
originalRunId,
|
||||
spanId,
|
||||
environmentId,
|
||||
}: {
|
||||
originalRunId?: string;
|
||||
spanId: string;
|
||||
environmentId: string;
|
||||
}) {
|
||||
const run = await this._replica.taskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -397,12 +425,14 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: span.originalRun
|
||||
where: originalRunId
|
||||
? {
|
||||
friendlyId: span.originalRun,
|
||||
friendlyId: originalRunId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
}
|
||||
: {
|
||||
spanId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -411,6 +441,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
async #getSpan({
|
||||
eventStore,
|
||||
eventRepository,
|
||||
traceId,
|
||||
spanId,
|
||||
environmentId,
|
||||
@@ -418,6 +449,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
createdAt,
|
||||
completedAt,
|
||||
}: {
|
||||
eventRepository: IEventRepository;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
environmentId: string;
|
||||
@@ -428,12 +460,14 @@ export class SpanPresenter extends BasePresenter {
|
||||
}) {
|
||||
const span = await eventRepository.getSpan(
|
||||
eventStore,
|
||||
environmentId,
|
||||
spanId,
|
||||
traceId,
|
||||
createdAt,
|
||||
completedAt ?? undefined,
|
||||
{ includeDebugLogs: true }
|
||||
);
|
||||
|
||||
if (!span) {
|
||||
return;
|
||||
}
|
||||
@@ -445,11 +479,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: true,
|
||||
createdAt: true,
|
||||
number: true,
|
||||
lockedToVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
taskVersion: true,
|
||||
},
|
||||
where: {
|
||||
parentSpanId: spanId,
|
||||
@@ -457,11 +487,32 @@ export class SpanPresenter extends BasePresenter {
|
||||
});
|
||||
|
||||
const data = {
|
||||
...span,
|
||||
spanId: span.spanId,
|
||||
parentId: span.parentId,
|
||||
message: span.message,
|
||||
isError: span.isError,
|
||||
isPartial: span.isPartial,
|
||||
isCancelled: span.isCancelled,
|
||||
level: span.level,
|
||||
startTime: span.startTime,
|
||||
duration: span.duration,
|
||||
events: span.events,
|
||||
properties: span.properties ? JSON.stringify(span.properties, null, 2) : undefined,
|
||||
style: span.style,
|
||||
properties:
|
||||
span.properties &&
|
||||
typeof span.properties === "object" &&
|
||||
Object.keys(span.properties).length > 0
|
||||
? JSON.stringify(span.properties, null, 2)
|
||||
: undefined,
|
||||
resourceProperties:
|
||||
span.resourceProperties &&
|
||||
typeof span.resourceProperties === "object" &&
|
||||
Object.keys(span.resourceProperties).length > 0
|
||||
? JSON.stringify(span.resourceProperties, null, 2)
|
||||
: undefined,
|
||||
entity: span.entity,
|
||||
metadata: span.metadata,
|
||||
triggeredRuns,
|
||||
showActionBar: span.show?.actions === true,
|
||||
};
|
||||
|
||||
switch (span.entity.type) {
|
||||
@@ -513,6 +564,41 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
};
|
||||
}
|
||||
case "realtime-stream": {
|
||||
if (!span.entity.id) {
|
||||
logger.error(`SpanPresenter: No realtime stream id`, {
|
||||
spanId,
|
||||
realtimeStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const [runId, streamKey] = span.entity.id.split(":");
|
||||
|
||||
if (!runId || !streamKey) {
|
||||
logger.error(`SpanPresenter: Invalid realtime stream id`, {
|
||||
spanId,
|
||||
realtimeStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const metadata = span.entity.metadata
|
||||
? (safeJsonParse(span.entity.metadata) as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...data,
|
||||
entity: {
|
||||
type: "realtime-stream" as const,
|
||||
object: {
|
||||
runId,
|
||||
streamKey,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ClickHouse } from "@internal/clickhouse";
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
type TaskRunTemplate,
|
||||
type RuntimeEnvironmentType,
|
||||
type TaskRunStatus,
|
||||
type TaskRunTemplate,
|
||||
PrismaClientOrTransaction,
|
||||
} from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import parse from "parse-duration";
|
||||
import { type PrismaClient } from "~/db.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import { queueTypeFromType } from "./QueueRetrievePresenter.server";
|
||||
import parse from "parse-duration";
|
||||
|
||||
export type RunTemplate = TaskRunTemplate & {
|
||||
scheduledTaskPayload?: ScheduledRun["payload"];
|
||||
@@ -20,6 +23,8 @@ type TestTaskOptions = {
|
||||
environment: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
taskIdentifier: string;
|
||||
};
|
||||
@@ -111,11 +116,10 @@ export type ScheduledRun = Omit<RawRun, "payload" | "ttl"> & {
|
||||
};
|
||||
|
||||
export class TestTaskPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
) {}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
@@ -128,7 +132,7 @@ export class TestTaskPresenter {
|
||||
? (
|
||||
await findCurrentWorkerDeployment({ environmentId: environment.id })
|
||||
)?.worker?.tasks.find((t) => t.slug === taskIdentifier)
|
||||
: await this.#prismaClient.backgroundWorkerTask.findFirst({
|
||||
: await this.replica.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: taskIdentifier,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
@@ -145,7 +149,7 @@ export class TestTaskPresenter {
|
||||
}
|
||||
|
||||
const taskQueue = task.queueId
|
||||
? await this.#prismaClient.taskQueue.findFirst({
|
||||
? await this.replica.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
id: task.queueId,
|
||||
@@ -159,7 +163,7 @@ export class TestTaskPresenter {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const backgroundWorkers = await this.#prismaClient.backgroundWorker.findMany({
|
||||
const backgroundWorkers = await this.replica.backgroundWorker.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
@@ -173,7 +177,7 @@ export class TestTaskPresenter {
|
||||
take: 20, // last 20 versions should suffice
|
||||
});
|
||||
|
||||
const taskRunTemplates = await this.#prismaClient.taskRunTemplate.findMany({
|
||||
const taskRunTemplates = await this.replica.taskRunTemplate.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
taskSlug: task.slug,
|
||||
@@ -190,47 +194,55 @@ export class TestTaskPresenter {
|
||||
const disableVersionSelection = environment.type === "DEVELOPMENT";
|
||||
const allowArbitraryQueues = backgroundWorkers[0]?.engine === "V1";
|
||||
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
|
||||
WITH taskruns AS (
|
||||
SELECT
|
||||
tr.*
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
ON
|
||||
tr."taskIdentifier" = bwt.slug
|
||||
WHERE
|
||||
bwt."friendlyId" = ${task.friendlyId} AND
|
||||
tr."runtimeEnvironmentId" = ${environment.id}
|
||||
ORDER BY
|
||||
tr."createdAt" DESC
|
||||
LIMIT 10
|
||||
)
|
||||
SELECT
|
||||
taskr.id,
|
||||
taskr."queue",
|
||||
taskr."friendlyId",
|
||||
taskr."taskIdentifier",
|
||||
taskr."createdAt",
|
||||
taskr.status,
|
||||
taskr.payload,
|
||||
taskr."payloadType",
|
||||
taskr."seedMetadata",
|
||||
taskr."seedMetadataType",
|
||||
taskr."runtimeEnvironmentId",
|
||||
taskr."concurrencyKey",
|
||||
taskr."maxAttempts",
|
||||
taskr."maxDurationInSeconds",
|
||||
taskr."machinePreset",
|
||||
taskr."ttl",
|
||||
taskr."runTags"
|
||||
FROM
|
||||
taskruns AS taskr
|
||||
WHERE
|
||||
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
|
||||
ORDER BY
|
||||
taskr."createdAt" DESC;`;
|
||||
// Get the latest runs, for the payloads
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica as PrismaClient,
|
||||
});
|
||||
|
||||
const runIds = await runsRepository.listRunIds({
|
||||
organizationId: environment.organizationId,
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
tasks: [task.slug],
|
||||
period: "30d",
|
||||
page: {
|
||||
size: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const latestRuns = await this.replica.taskRun.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
queue: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
createdAt: true,
|
||||
status: true,
|
||||
payload: true,
|
||||
payloadType: true,
|
||||
seedMetadata: true,
|
||||
seedMetadataType: true,
|
||||
runtimeEnvironmentId: true,
|
||||
concurrencyKey: true,
|
||||
maxAttempts: true,
|
||||
maxDurationInSeconds: true,
|
||||
machinePreset: true,
|
||||
ttl: true,
|
||||
runTags: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: runIds,
|
||||
},
|
||||
payloadType: {
|
||||
in: ["application/json", "application/super+json"],
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const taskWithEnvironment = {
|
||||
id: task.id,
|
||||
@@ -258,6 +270,12 @@ export class TestTaskPresenter {
|
||||
async (r) =>
|
||||
({
|
||||
...r,
|
||||
seedMetadata: r.seedMetadata ?? undefined,
|
||||
seedMetadataType: r.seedMetadataType ?? undefined,
|
||||
concurrencyKey: r.concurrencyKey ?? undefined,
|
||||
maxAttempts: r.maxAttempts ?? undefined,
|
||||
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
|
||||
machinePreset: r.machinePreset ?? undefined,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
metadata: r.seedMetadata
|
||||
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
|
||||
@@ -300,6 +318,12 @@ export class TestTaskPresenter {
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
seedMetadata: r.seedMetadata ?? undefined,
|
||||
seedMetadataType: r.seedMetadataType ?? undefined,
|
||||
concurrencyKey: r.concurrencyKey ?? undefined,
|
||||
maxAttempts: r.maxAttempts ?? undefined,
|
||||
maxDurationInSeconds: r.maxDurationInSeconds ?? undefined,
|
||||
machinePreset: r.machinePreset ?? undefined,
|
||||
payload: payload.data,
|
||||
ttlSeconds: r.ttl ? parse(r.ttl, "s") ?? undefined : undefined,
|
||||
} satisfies ScheduledRun;
|
||||
|
||||
@@ -20,6 +20,13 @@ export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: tailwindStylesheetUrl }];
|
||||
};
|
||||
|
||||
export const headers = () => ({
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Permissions-Policy":
|
||||
"geolocation=(), microphone=(), camera=(), accelerometer=(), gyroscope=(), magnetometer=(), payment=(), usb=()",
|
||||
});
|
||||
|
||||
export const meta: MetaFunction = ({ data }) => {
|
||||
const typedData = data as UseDataFunctionReturn<typeof loader>;
|
||||
return [
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
|
||||
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import {
|
||||
redirectWithErrorMessage,
|
||||
setRequestSuccessMessage,
|
||||
commitSession,
|
||||
} from "~/models/message.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { $replica } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
@@ -41,7 +37,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
logger.warn("GitHub App callback with invalid params", {
|
||||
queryParams,
|
||||
});
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const callbackData = result.data;
|
||||
@@ -54,7 +50,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
error: sessionResult.error,
|
||||
});
|
||||
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
|
||||
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
|
||||
@@ -76,7 +72,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
switch (callbackData.setup_action) {
|
||||
@@ -89,17 +85,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
logger.error("Failed to link GitHub App installation", {
|
||||
error,
|
||||
});
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
|
||||
const session = await setRequestSuccessMessage(request, "GitHub App installed successfully");
|
||||
session.flash("gitHubAppInstalled", true);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
|
||||
}
|
||||
|
||||
case "update": {
|
||||
@@ -112,14 +101,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
|
||||
}
|
||||
|
||||
const session = await setRequestSuccessMessage(request, "GitHub App updated successfully");
|
||||
session.flash("gitHubAppInstalled", true);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
|
||||
}
|
||||
|
||||
case "request": {
|
||||
@@ -129,17 +111,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
callbackData,
|
||||
});
|
||||
|
||||
const session = await setRequestSuccessMessage(request, "GitHub App installation requested");
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
|
||||
}
|
||||
|
||||
default:
|
||||
callbackData satisfies never;
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
|
||||
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-24
@@ -1,10 +1,9 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useRevalidator } from "@remix-run/react";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import type { BulkActionType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
@@ -18,8 +17,9 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { env } from "~/env.server";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -72,7 +72,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return typedjson({ bulkAction: data });
|
||||
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({ bulkAction: data, autoReloadPollIntervalMs });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -130,30 +132,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { bulkAction } = useTypedLoaderData<typeof loader>();
|
||||
const { bulkAction, autoReloadPollIntervalMs } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const disabled = bulkAction.status !== "PENDING";
|
||||
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.id}/runs/bulkaction/${bulkAction.friendlyId}/stream`,
|
||||
{
|
||||
event: "progress",
|
||||
disabled,
|
||||
}
|
||||
);
|
||||
|
||||
const revalidation = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || streamedEvents === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidation.revalidate();
|
||||
}, [streamedEvents, disabled]);
|
||||
useAutoRevalidate({
|
||||
interval: autoReloadPollIntervalMs,
|
||||
onFocus: true,
|
||||
disabled: bulkAction.status !== "PENDING",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
import { conform, useFieldList, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
ManageConcurrencyPresenter,
|
||||
type ConcurrencyResult,
|
||||
type EnvironmentWithConcurrency,
|
||||
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
|
||||
import { getPlans } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Manage concurrency | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new ManageConcurrencyPresenter();
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId: userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = await tryCatch(getPlans());
|
||||
if (!plans) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
|
||||
const FormSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.enum(["purchase"]),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["quota-increase"]),
|
||||
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.enum(["allocate"]),
|
||||
// It will only update environments that are passed in
|
||||
environments: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
|
||||
})
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const redirectPath = concurrencyPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(redirectPath, request, "Project not found");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: FormSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (submission.value.action === "allocate") {
|
||||
const allocate = new AllocateConcurrencyService();
|
||||
const [error, result] = await tryCatch(
|
||||
allocate.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
environments: submission.value.environments,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.environments = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.environments = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
"Concurrency allocated successfully"
|
||||
);
|
||||
}
|
||||
|
||||
const service = new SetConcurrencyAddOnService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
organizationId: project.organizationId,
|
||||
action: submission.value.action,
|
||||
amount: submission.value.amount,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.amount = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
`${redirectPath}?success=true`,
|
||||
request,
|
||||
submission.value.action === "purchase"
|
||||
? "Concurrency updated successfully"
|
||||
: "Requested extra concurrency, we'll get back to you soon."
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
canAddConcurrency,
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Concurrency" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<Property.Table>
|
||||
{environments.map((environment) => (
|
||||
<Property.Item key={environment.id}>
|
||||
<Property.Label>
|
||||
{environment.type}{" "}
|
||||
{environment.branchName ? ` (${environment.branchName})` : ""}
|
||||
</Property.Label>
|
||||
<Property.Value>{environment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<MainHorizontallyCenteredContainer>
|
||||
{canAddConcurrency ? (
|
||||
<Upgradable
|
||||
canAddConcurrency={canAddConcurrency}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraAllocatedConcurrency={extraAllocatedConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
environments={environments}
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
maxQuota={maxQuota}
|
||||
/>
|
||||
) : (
|
||||
<NotUpgradable environments={environments} />
|
||||
)}
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function initialAllocation(environments: ConcurrencyResult["environments"]) {
|
||||
return new Map<string, number>(
|
||||
environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
|
||||
);
|
||||
}
|
||||
|
||||
function allocationTotal(environments: ConcurrencyResult["environments"]) {
|
||||
const allocation = initialAllocation(environments);
|
||||
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
}
|
||||
|
||||
function Upgradable({
|
||||
extraConcurrency,
|
||||
extraAllocatedConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
environments,
|
||||
concurrencyPricing,
|
||||
maxQuota,
|
||||
}: ConcurrencyResult) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form, { environments: formEnvironments }] = useForm({
|
||||
id: "purchase-concurrency",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
const [allocation, setAllocation] = useState(initialAllocation(environments));
|
||||
|
||||
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
|
||||
const initialAllocationInProject = allocationTotal(environments);
|
||||
const changeInAllocation = allocatedInProject - initialAllocationInProject;
|
||||
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
|
||||
const allocationModified = changeInAllocation !== 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Manage your concurrency</Header2>
|
||||
</div>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can add
|
||||
extra concurrency to your organization which you can allocate to environments in your
|
||||
projects.
|
||||
</Paragraph>
|
||||
<div className="mt-3 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center first-letter:pb-1">
|
||||
<Header3 className="grow">Extra concurrency</Header3>
|
||||
<PurchaseConcurrencyModal
|
||||
concurrencyPricing={concurrencyPricing}
|
||||
extraConcurrency={extraConcurrency}
|
||||
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
|
||||
maxQuota={maxQuota}
|
||||
disabled={unallocated < 0 ? false : allocationModified}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
|
||||
<TableCell alignment="right" className="tabular-nums text-text-bright">
|
||||
{extraConcurrency}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Allocated concurrency</TableCell>
|
||||
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraAllocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraAllocatedConcurrency + changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraAllocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Unallocated concurrency</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
unallocated > 0
|
||||
? "text-success"
|
||||
: unallocated < 0
|
||||
? "text-error"
|
||||
: "text-text-bright"
|
||||
)}
|
||||
>
|
||||
{allocationModified ? (
|
||||
<>
|
||||
<span className="text-text-dimmed line-through">
|
||||
{extraUnallocatedConcurrency}
|
||||
</span>{" "}
|
||||
{extraUnallocatedConcurrency - changeInAllocation}
|
||||
</>
|
||||
) : (
|
||||
extraUnallocatedConcurrency
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow className={allocationModified ? undefined : "after:bg-transparent"}>
|
||||
<TableCell colSpan={2} className="py-0">
|
||||
<div className="flex h-10 items-center">
|
||||
{allocationModified ? (
|
||||
unallocated < 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<ExclamationTriangleIcon className="size-4 text-error" />
|
||||
<span className="text-error">
|
||||
You're trying to allocate more concurrency than your total purchased
|
||||
amount.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<InformationCircleIcon className="size-4 text-text-dimmed" />
|
||||
<span>
|
||||
Save your changes or{" "}
|
||||
<button
|
||||
className="inline text-indigo-500 hover:text-indigo-300"
|
||||
onClick={() => {
|
||||
setAllocation(initialAllocation(environments));
|
||||
}}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
type="submit"
|
||||
form="allocate"
|
||||
disabled={unallocated < 0 || isLoading}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<FormError id={formEnvironments.id}>{formEnvironments.error}</FormError>
|
||||
</div>
|
||||
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
|
||||
<input type="hidden" name="action" value="allocate" />
|
||||
<div className="flex items-center pb-1">
|
||||
<Header3 className="grow">Concurrency allocation</Header3>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">
|
||||
<span className="flex items-center justify-end gap-x-1">
|
||||
Included{" "}
|
||||
<InfoIconTooltip content="This is the included concurrency based on your plan." />
|
||||
</span>
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Total</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment, index) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<div className="flex items-center justify-end">
|
||||
{environment.type === "DEVELOPMENT" ? (
|
||||
Math.max(
|
||||
0,
|
||||
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`environments[${index}].id`}
|
||||
value={environment.id}
|
||||
/>
|
||||
<Input
|
||||
name={`environments[${index}].amount`}
|
||||
type="number"
|
||||
variant="outline/small"
|
||||
className="text-right"
|
||||
containerClassName="w-16"
|
||||
fullWidth={false}
|
||||
value={allocation.get(environment.id)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
setAllocation(new Map(allocation).set(environment.id, value));
|
||||
}}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="border-b border-grid-dimmed pb-1">
|
||||
<Header2>Your concurrency</Header2>
|
||||
</div>
|
||||
{isManagedCloud ? (
|
||||
<>
|
||||
<Paragraph variant="small">
|
||||
Concurrency limits determine how many runs you can execute at the same time. You can
|
||||
upgrade your plan to get more concurrency. You are currently on the{" "}
|
||||
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade for more concurrency
|
||||
</LinkButton>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{environments.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell className="pl-0">
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PurchaseConcurrencyModal({
|
||||
concurrencyPricing,
|
||||
extraConcurrency,
|
||||
extraUnallocatedConcurrency,
|
||||
maxQuota,
|
||||
disabled,
|
||||
}: {
|
||||
concurrencyPricing: {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
extraConcurrency: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
maxQuota: number;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
const [form, { amount }] = useForm({
|
||||
id: "purchase-concurrency",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const [amountValue, setAmountValue] = useState(extraConcurrency);
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
|
||||
|
||||
// Close the panel, when we've succeeded
|
||||
// This is required because a redirect to the same path doesn't clear state
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const success = searchParams.get("success");
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
setSearchParams((s) => {
|
||||
s.delete("success");
|
||||
return s;
|
||||
});
|
||||
}
|
||||
}, [searchParams.get("success")]);
|
||||
|
||||
const state = updateState({
|
||||
value: amountValue,
|
||||
existingValue: extraConcurrency,
|
||||
quota: maxQuota,
|
||||
extraUnallocatedConcurrency,
|
||||
});
|
||||
const changeClassName =
|
||||
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
||||
|
||||
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<Paragraph variant="base/bright" spacing>
|
||||
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
|
||||
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
|
||||
remove any extra concurrency after you have unallocated it from your environments
|
||||
first.
|
||||
</Paragraph>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="amount" className="text-text-dimmed">
|
||||
Total extra concurrency
|
||||
</Label>
|
||||
<InputNumberStepper
|
||||
{...conform.input(amount, { type: "number" })}
|
||||
step={concurrencyPricing.stepSize}
|
||||
min={0}
|
||||
max={undefined}
|
||||
value={amountValue}
|
||||
onChange={(e) => setAmountValue(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<FormError id={amount.errorId}>{amount.error}</FormError>
|
||||
<FormError>{form.error}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
{state === "need_to_increase_unallocated" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
You need to unallocate{" "}
|
||||
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
|
||||
concurrency from your environments in order to remove{" "}
|
||||
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : state === "above_quota" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
Currently you can only have up to {maxQuota} extra concurrency. Send a request
|
||||
below to lift your current limit. We'll get back to you soon.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-3 tabular-nums">
|
||||
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
||||
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
|
||||
current total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(extraConcurrency * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({extraConcurrency / concurrencyPricing.stepSize} bundles)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatNumber(amountValue - extraConcurrency)}
|
||||
</Header3>
|
||||
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatCurrency(
|
||||
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({(amountValue - extraConcurrency) / concurrencyPricing.stepSize} bundles @{" "}
|
||||
{formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(
|
||||
(amountValue * concurrencyPricing.centsPerStep) /
|
||||
concurrencyPricing.stepSize /
|
||||
100,
|
||||
true
|
||||
)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({amountValue / concurrencyPricing.stepSize} bundles)
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
state === "above_quota" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="quota-increase" />
|
||||
<Button
|
||||
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{`Send request for ${formatNumber(amountValue)}`}
|
||||
</Button>
|
||||
</>
|
||||
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "need_to_increase_unallocated"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function updateState({
|
||||
value,
|
||||
existingValue,
|
||||
quota,
|
||||
extraUnallocatedConcurrency,
|
||||
}: {
|
||||
value: number;
|
||||
existingValue: number;
|
||||
quota: number;
|
||||
extraUnallocatedConcurrency: number;
|
||||
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
|
||||
if (value === existingValue) return "no_change";
|
||||
if (value < existingValue) {
|
||||
const difference = existingValue - value;
|
||||
if (difference > extraUnallocatedConcurrency) {
|
||||
return "need_to_increase_unallocated";
|
||||
}
|
||||
return "decrease";
|
||||
}
|
||||
if (value > quota) return "above_quota";
|
||||
return "increase";
|
||||
}
|
||||
+353
-7
@@ -1,10 +1,12 @@
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { S2, S2Error } from "@s2-dev/streamstore";
|
||||
import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
@@ -23,16 +25,22 @@ import {
|
||||
} from "~/components/primitives/Table";
|
||||
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
|
||||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { capitalizeWord } from "~/utils/string";
|
||||
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
|
||||
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -41,7 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new DeploymentPresenter();
|
||||
const { deployment } = await presenter.call({
|
||||
const { deployment, eventStream } = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
@@ -49,7 +57,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
deploymentShortCode: deploymentParam,
|
||||
});
|
||||
|
||||
return typedjson({ deployment });
|
||||
return typedjson({ deployment, eventStream });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -59,15 +67,122 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
level: "info" | "error" | "warn" | "debug";
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { deployment } = useTypedLoaderData<typeof loader>();
|
||||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
const user = useUser();
|
||||
const page = new URLSearchParams(location.search).get("page");
|
||||
|
||||
const logsDisabled = eventStream === undefined;
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(true);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const isPending = deployment.status === "PENDING";
|
||||
|
||||
useEffect(() => {
|
||||
if (logsDisabled) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setLogs([]);
|
||||
setStreamError(null);
|
||||
setIsStreaming(true);
|
||||
|
||||
const streamLogs = async () => {
|
||||
try {
|
||||
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
|
||||
const basin = s2.basin(eventStream.s2.basin);
|
||||
const stream = basin.stream(eventStream.s2.stream);
|
||||
|
||||
const readSession = await stream.readSession(
|
||||
{
|
||||
seq_num: 0,
|
||||
wait: 60,
|
||||
as: "bytes",
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
for await (const record of readSession) {
|
||||
const decoded = decoder.decode(record.body);
|
||||
const result = DeploymentEventFromString.safeParse(decoded);
|
||||
|
||||
if (!result.success) {
|
||||
// fallback to the previous format in s2 logs for compatibility
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (record.headers) {
|
||||
for (const [nameBytes, valueBytes] of record.headers) {
|
||||
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
|
||||
}
|
||||
}
|
||||
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: decoded,
|
||||
level,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse log record:", err);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = result.data;
|
||||
if (event.type !== "log") {
|
||||
continue;
|
||||
}
|
||||
|
||||
setLogs((prevLogs) => [
|
||||
...prevLogs,
|
||||
{
|
||||
timestamp: new Date(record.timestamp),
|
||||
message: event.data.message,
|
||||
level: event.data.level,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
const isNotFoundError =
|
||||
error instanceof S2Error &&
|
||||
error.code &&
|
||||
["permission_denied", "stream_not_found"].includes(error.code);
|
||||
if (isNotFoundError) return;
|
||||
|
||||
console.error("Failed to stream logs:", error);
|
||||
setStreamError("Failed to stream logs");
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
streamLogs();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
@@ -132,7 +247,11 @@ export default function Page() {
|
||||
<Property.Label>Deploy</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{deployment.shortCode}</span>
|
||||
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
|
||||
{deployment.label && (
|
||||
<Badge variant="extra-small" className="capitalize">
|
||||
{deployment.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
@@ -155,6 +274,35 @@ export default function Page() {
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{!logsDisabled && (
|
||||
<Property.Item>
|
||||
<Property.Label>Logs</Property.Label>
|
||||
<LogsDisplay
|
||||
logs={logs}
|
||||
isStreaming={isStreaming}
|
||||
streamError={streamError}
|
||||
initialCollapsed={(
|
||||
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
|
||||
).includes(deployment.status)}
|
||||
/>
|
||||
</Property.Item>
|
||||
)}
|
||||
{deployment.canceledAt && (
|
||||
<Property.Item>
|
||||
<Property.Label>Canceled at</Property.Label>
|
||||
<Property.Value>
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.canceledAt} /> UTC
|
||||
</>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{deployment.canceledReason && (
|
||||
<Property.Item>
|
||||
<Property.Label>Cancelation reason</Property.Label>
|
||||
<Property.Value>{deployment.canceledReason}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Tasks</Property.Label>
|
||||
<Property.Value>{deployment.tasks ? deployment.tasks.length : "–"}</Property.Value>
|
||||
@@ -197,6 +345,18 @@ export default function Page() {
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Installed at</Property.Label>
|
||||
<Property.Value>
|
||||
{deployment.installedAt ? (
|
||||
<>
|
||||
<DateTimeAccurate date={deployment.installedAt} /> UTC
|
||||
</>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Built at</Property.Label>
|
||||
<Property.Value>
|
||||
@@ -289,3 +449,189 @@ export default function Page() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsDisplay({
|
||||
logs,
|
||||
isStreaming,
|
||||
streamError,
|
||||
initialCollapsed = false,
|
||||
}: {
|
||||
logs: LogEntry[];
|
||||
isStreaming: boolean;
|
||||
streamError: string | null;
|
||||
initialCollapsed?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(initialCollapsed);
|
||||
}, [initialCollapsed]);
|
||||
|
||||
// auto-scroll log container to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (logsContainerRef.current) {
|
||||
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
const onCopyLogs = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const logsText = logs.map((log) => log.message).join("\n");
|
||||
navigator.clipboard.writeText(logsText);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[logs]
|
||||
);
|
||||
|
||||
const errorCount = logs.filter((log) => log.level === "error").length;
|
||||
const warningCount = logs.filter((log) => log.level === "warn").length;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
errorCount > 0 ? "bg-error/80" : "bg-charcoal-600"
|
||||
)}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
|
||||
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
warningCount > 0 ? "bg-warning/80" : "bg-charcoal-600"
|
||||
)}
|
||||
/>
|
||||
<Paragraph variant="extra-small/dimmed/mono">
|
||||
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={onCopyLogs}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="size-4 shrink-0">
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-full" />
|
||||
) : (
|
||||
<Clipboard className="size-full" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
"text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronUp className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{collapsed ? "Expand" : "Collapse"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className={cn(
|
||||
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
collapsed ? "h-16" : "h-64"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-fit min-w-full flex-col">
|
||||
{logs.length === 0 && (
|
||||
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
|
||||
{streamError ? (
|
||||
<span className="text-error">Failed fetching logs</span>
|
||||
) : (
|
||||
<span className="text-text-dimmed">
|
||||
{isStreaming ? "Waiting for logs..." : "No logs yet"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{logs.map((log, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
|
||||
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
|
||||
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
|
||||
log.level === "info" && "border-transparent hover:bg-charcoal-750"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"select-none whitespace-nowrap py-px",
|
||||
log.level === "error" && "text-error/80",
|
||||
log.level === "warn" && "text-warning/70",
|
||||
log.level === "info" && "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap",
|
||||
log.level === "error" && "text-error",
|
||||
log.level === "warn" && "text-warning",
|
||||
log.level === "info" && "text-text-bright"
|
||||
)}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-charcoal-800/90 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+189
-14
@@ -1,5 +1,18 @@
|
||||
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
BookOpenIcon,
|
||||
NoSymbolIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useParams,
|
||||
} from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { CogIcon, GitBranchIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
@@ -15,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
@@ -39,10 +60,6 @@ import {
|
||||
deploymentStatusDescription,
|
||||
deploymentStatuses,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
PromoteDeploymentDialog,
|
||||
RollbackDeploymentDialog,
|
||||
} from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
@@ -61,6 +78,9 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { env } from "~/env.server";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -116,7 +136,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
? result.deployments.find((d) => d.version === version)
|
||||
: undefined;
|
||||
|
||||
return typedjson({ ...result, selectedDeployment });
|
||||
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({ ...result, selectedDeployment, autoReloadPollIntervalMs });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
@@ -137,6 +159,7 @@ export default function Page() {
|
||||
selectedDeployment,
|
||||
connectedGithubRepository,
|
||||
environmentGitHubBranch,
|
||||
autoReloadPollIntervalMs,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const hasDeployments = totalPages > 0;
|
||||
|
||||
@@ -144,6 +167,8 @@ export default function Page() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
// If we have a selected deployment from the version param, show it
|
||||
useEffect(() => {
|
||||
if (selectedDeployment && !deploymentParam) {
|
||||
@@ -334,11 +359,11 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
@@ -347,7 +372,7 @@ export default function Page() {
|
||||
{deploymentParam && (
|
||||
<>
|
||||
<ResizableHandle id="deployments-handle" />
|
||||
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
|
||||
<ResizablePanel id="deployments-inspector" min="500px" max="800px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
@@ -388,7 +413,10 @@ function DeploymentActionsCell({
|
||||
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
|
||||
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
|
||||
|
||||
if (!canBeRolledBack && !canBePromoted) {
|
||||
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
|
||||
const canBeCanceled = !finalStatuses.includes(deployment.status);
|
||||
|
||||
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
@@ -412,7 +440,7 @@ function DeploymentActionsCell({
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Rollback…
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RollbackDeploymentDialog
|
||||
@@ -432,7 +460,7 @@ function DeploymentActionsCell({
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Promote…
|
||||
Promote
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<PromoteDeploymentDialog
|
||||
@@ -442,8 +470,155 @@ function DeploymentActionsCell({
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canBeCanceled && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CancelDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
function RollbackDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CancelDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="cancel">
|
||||
<DialogHeader>Cancel this deployment?</DialogHeader>
|
||||
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Back</Button>
|
||||
</DialogClose>
|
||||
<Form action={formAction} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : "Cancel deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
+336
-106
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowUpCircleIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
@@ -8,19 +9,14 @@ import {
|
||||
RectangleStackIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
Form,
|
||||
useNavigate,
|
||||
useNavigation,
|
||||
useRevalidator,
|
||||
useSearchParams,
|
||||
type MetaFunction,
|
||||
} from "@remix-run/react";
|
||||
import { Form, useNavigation, useSearchParams, type MetaFunction } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
|
||||
import upgradeForQueuesPath from "~/assets/images/queues-dashboard.png";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
@@ -30,13 +26,16 @@ import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BigNumber } from "~/components/metrics/BigNumber";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, ButtonVariant, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -55,25 +54,32 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getUserById } from "~/models/user.server";
|
||||
import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server";
|
||||
import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, EnvironmentParamSchema, v3BillingPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
concurrencyPath,
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3BillingPath,
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
|
||||
import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
|
||||
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
query: z.string().optional(),
|
||||
@@ -121,9 +127,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const environmentQueuePresenter = new EnvironmentQueuePresenter();
|
||||
|
||||
const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS;
|
||||
|
||||
return typedjson({
|
||||
...queues,
|
||||
environment: await environmentQueuePresenter.call(environment),
|
||||
autoReloadPollIntervalMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -211,34 +220,98 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
|
||||
);
|
||||
}
|
||||
case "queue-override": {
|
||||
const friendlyId = formData.get("friendlyId");
|
||||
const concurrencyLimit = formData.get("concurrencyLimit");
|
||||
|
||||
if (!friendlyId) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
|
||||
}
|
||||
|
||||
if (!concurrencyLimit) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
|
||||
}
|
||||
|
||||
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
|
||||
if (isNaN(limitNumber) || limitNumber < 0) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Concurrency limit must be a valid number"
|
||||
);
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
if (!user) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "User not found");
|
||||
}
|
||||
|
||||
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
|
||||
environment,
|
||||
friendlyId.toString(),
|
||||
limitNumber,
|
||||
user
|
||||
);
|
||||
|
||||
if (!result.isOk()) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Failed to override queue concurrency limit"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Queue concurrency limit overridden"
|
||||
);
|
||||
}
|
||||
case "queue-remove-override": {
|
||||
const friendlyId = formData.get("friendlyId");
|
||||
|
||||
if (!friendlyId) {
|
||||
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
|
||||
}
|
||||
|
||||
const result = await concurrencySystem.queues.resetConcurrencyLimit(
|
||||
environment,
|
||||
friendlyId.toString()
|
||||
);
|
||||
|
||||
if (!result.isOk()) {
|
||||
return redirectWithErrorMessage(
|
||||
redirectPath,
|
||||
request,
|
||||
"Failed to reset queue concurrency limit"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
|
||||
}
|
||||
default:
|
||||
return redirectWithErrorMessage(redirectPath, request, "Something went wrong");
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { environment, queues, success, pagination, code, totalQueues, hasFilters } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const {
|
||||
environment,
|
||||
queues,
|
||||
success,
|
||||
pagination,
|
||||
code,
|
||||
totalQueues,
|
||||
hasFilters,
|
||||
autoReloadPollIntervalMs,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const env = useEnvironment();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
// Reload the page periodically
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${env.slug}/queues/stream`,
|
||||
{
|
||||
event: "update",
|
||||
}
|
||||
);
|
||||
|
||||
const revalidation = useRevalidator();
|
||||
useEffect(() => {
|
||||
if (streamedEvents) {
|
||||
revalidation.revalidate();
|
||||
}
|
||||
}, [streamedEvents]);
|
||||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||||
|
||||
const limitStatus =
|
||||
environment.running === environment.concurrencyLimit * environment.burstFactor
|
||||
@@ -275,17 +348,19 @@ export default function Page() {
|
||||
animate
|
||||
accessory={
|
||||
<div className="flex items-start gap-1">
|
||||
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
className="px-2"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
statuses: ["PENDING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
{environment.runsEnabled ? <EnvironmentPauseResumeButton env={env} /> : null}
|
||||
tooltip="View queued runs"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
valueClassName={env.paused ? "text-warning" : undefined}
|
||||
@@ -308,15 +383,17 @@ export default function Page() {
|
||||
}
|
||||
accessory={
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
className="px-2"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
statuses: ["DEQUEUED", "EXECUTING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
>
|
||||
View runs
|
||||
</LinkButton>
|
||||
tooltip="View runs"
|
||||
/>
|
||||
}
|
||||
compactThreshold={1000000}
|
||||
/>
|
||||
@@ -336,18 +413,14 @@ export default function Page() {
|
||||
accessory={
|
||||
plan ? (
|
||||
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={ChatBubbleLeftEllipsisIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Increase limit…
|
||||
</Button>
|
||||
}
|
||||
defaultValue="concurrency"
|
||||
/>
|
||||
<LinkButton
|
||||
to={concurrencyPath(organization, project, env)}
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={ConcurrencyIcon}
|
||||
leadingIconClassName="text-amber-500"
|
||||
>
|
||||
Increase limit
|
||||
</LinkButton>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
|
||||
@@ -383,7 +456,8 @@ export default function Page() {
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running/limit</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
alignment="right"
|
||||
tooltip={
|
||||
@@ -409,6 +483,17 @@ export default function Page() {
|
||||
This queue is limited by a concurrency limit set in your code.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Header3>Override</Header3>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="!text-wrap text-text-dimmed"
|
||||
spacing
|
||||
>
|
||||
This queue's concurrency limit has been manually overridden from the
|
||||
dashboard or API.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -423,7 +508,7 @@ export default function Page() {
|
||||
{queues.length > 0 ? (
|
||||
queues.map((queue) => {
|
||||
const limit = queue.concurrencyLimit ?? environment.concurrencyLimit;
|
||||
const isAtLimit = queue.running === limit;
|
||||
const isAtLimit = queue.running >= limit;
|
||||
const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${
|
||||
queue.name
|
||||
}`;
|
||||
@@ -459,6 +544,18 @@ export default function Page() {
|
||||
<span className={queue.paused ? "opacity-50" : undefined}>
|
||||
{queue.name}
|
||||
</span>
|
||||
{queue.concurrency?.overriddenAt ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className="text-text-bright">
|
||||
Concurrency limit overridden
|
||||
</Badge>
|
||||
}
|
||||
content="This queue's concurrency limit has been manually overridden from the dashboard or API."
|
||||
className="max-w-xs"
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : null}
|
||||
{queue.paused ? (
|
||||
<Badge variant="extra-small" className="text-warning">
|
||||
Paused
|
||||
@@ -473,31 +570,50 @@ export default function Page() {
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={queue.paused ? "opacity-50" : undefined}
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined
|
||||
)}
|
||||
>
|
||||
{queue.queued}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
queue.paused ? "tabular-nums opacity-50" : undefined,
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
queue.running > 0 && "text-text-bright",
|
||||
isAtLimit && "text-warning"
|
||||
)}
|
||||
>
|
||||
{queue.running}/
|
||||
<span className={cn("tabular-nums", isAtLimit && "text-warning")}>
|
||||
{limit}
|
||||
</span>
|
||||
{queue.running}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%] tabular-nums",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning"
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
>
|
||||
{queue.concurrencyLimit ? "User" : "Environment"}
|
||||
{limit}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
alignment="right"
|
||||
className={cn(
|
||||
"w-[1%]",
|
||||
queue.paused ? "opacity-50" : undefined,
|
||||
isAtLimit && "text-warning",
|
||||
queue.concurrency?.overriddenAt && "font-medium text-text-bright"
|
||||
)}
|
||||
>
|
||||
{queue.concurrency?.overriddenAt ? (
|
||||
<span className="text-text-bright">Override</span>
|
||||
) : queue.concurrencyLimit ? (
|
||||
"User"
|
||||
) : (
|
||||
"Environment"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
@@ -524,50 +640,43 @@ export default function Page() {
|
||||
showTooltip={false}
|
||||
/>
|
||||
)}
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
|
||||
<PopoverMenuItem
|
||||
icon={RunsIcon}
|
||||
leadingIconClassName="text-runs"
|
||||
title="View all runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={RunsIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
View all runs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={RectangleStackIcon}
|
||||
leadingIconClassName="text-queues"
|
||||
title="View queued runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
statuses: ["PENDING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={RectangleStackIcon}
|
||||
leadingIconClassName="text-queues"
|
||||
>
|
||||
View queued runs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={Spinner}
|
||||
leadingIconClassName="text-queues animate-none"
|
||||
title="View running runs"
|
||||
to={v3RunsPath(organization, project, env, {
|
||||
queues: [queueFilterableName],
|
||||
statuses: ["DEQUEUED", "EXECUTING"],
|
||||
period: "30d",
|
||||
rootOnly: false,
|
||||
})}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={Spinner}
|
||||
leadingIconClassName="size-4 animate-none"
|
||||
>
|
||||
View running runs
|
||||
</LinkButton>
|
||||
/>
|
||||
<QueueOverrideConcurrencyButton
|
||||
queue={queue}
|
||||
environmentConcurrencyLimit={environment.concurrencyLimit}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -576,7 +685,7 @@ export default function Page() {
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={7}>
|
||||
<div className="grid place-items-center py-6 text-text-dimmed">
|
||||
<Paragraph>
|
||||
{hasFilters
|
||||
@@ -733,29 +842,26 @@ function QueuePauseResumeButton({
|
||||
fullWidth?: boolean;
|
||||
showTooltip?: boolean;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
fullWidth={fullWidth}
|
||||
textAlignLeft={fullWidth}
|
||||
>
|
||||
{queue.paused ? "Resume..." : "Pause..."}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const trigger = showTooltip ? (
|
||||
<div>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
fullWidth={fullWidth}
|
||||
textAlignLeft={fullWidth}
|
||||
>
|
||||
{queue.paused ? "Resume..." : "Pause..."}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
@@ -767,7 +873,13 @@ function QueuePauseResumeButton({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<PopoverMenuItem
|
||||
icon={queue.paused ? PlayIcon : PauseIcon}
|
||||
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
|
||||
title={queue.paused ? "Resume..." : "Pause..."}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -814,6 +926,124 @@ function QueuePauseResumeButton({
|
||||
);
|
||||
}
|
||||
|
||||
function QueueOverrideConcurrencyButton({
|
||||
queue,
|
||||
environmentConcurrencyLimit,
|
||||
}: {
|
||||
queue: QueueItem;
|
||||
environmentConcurrencyLimit: number;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
|
||||
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
|
||||
);
|
||||
|
||||
const isOverridden = !!queue.concurrency?.overriddenAt;
|
||||
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state === "loading" || navigation.state === "idle") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.state]);
|
||||
|
||||
const isLoading = Boolean(
|
||||
navigation.formData?.get("action") === "queue-override" ||
|
||||
navigation.formData?.get("action") === "queue-remove-override"
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<PopoverMenuItem
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
title={isOverridden ? "Edit override…" : "Override limit…"}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
{isOverridden ? (
|
||||
<Paragraph>
|
||||
This queue's concurrency limit is currently overridden to {currentLimit}.
|
||||
{typeof queue.concurrency?.base === "number" &&
|
||||
` The original limit set in code was ${queue.concurrency.base}.`}{" "}
|
||||
You can update the override or remove it to restore the{" "}
|
||||
{typeof queue.concurrency?.base === "number"
|
||||
? "limit set in code"
|
||||
: "environment concurrency limit"}
|
||||
.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph>
|
||||
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
|
||||
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
|
||||
</Paragraph>
|
||||
)}
|
||||
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
|
||||
<input type="hidden" name="friendlyId" value={queue.id} />
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="concurrencyLimit" className="text-sm text-text-bright">
|
||||
Concurrency limit
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
name="concurrencyLimit"
|
||||
id="concurrencyLimit"
|
||||
min="0"
|
||||
max={environmentConcurrencyLimit}
|
||||
value={concurrencyLimit}
|
||||
onChange={(e) => setConcurrencyLimit(e.target.value)}
|
||||
placeholder={currentLimit.toString()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="queue-override"
|
||||
disabled={isLoading || !concurrencyLimit}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading && <Spinner color="white" />}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isOverridden ? "Update override" : "Override limit"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{isOverridden && (
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="queue-remove-override"
|
||||
disabled={isLoading}
|
||||
variant="danger/medium"
|
||||
>
|
||||
Remove override
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="tertiary/medium">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EngineVersionUpgradeCallout() {
|
||||
return (
|
||||
<div className="mt-4 flex max-w-lg flex-col gap-4 rounded-sm border border-grid-bright bg-background-bright px-4">
|
||||
|
||||
+292
-23
@@ -2,6 +2,7 @@ import {
|
||||
ArrowUturnLeftIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -68,7 +69,6 @@ import {
|
||||
eventBorderClassName,
|
||||
} from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
v3RunRedirectPath,
|
||||
v3RunSpanPath,
|
||||
v3RunStreamingPath,
|
||||
@@ -97,6 +98,14 @@ import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
|
||||
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -130,6 +139,103 @@ const resizableSettings = {
|
||||
|
||||
type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0];
|
||||
|
||||
type RunsListNavigation = {
|
||||
runs: Array<{ friendlyId: string; spanId: string }>;
|
||||
pagination: { next?: string; previous?: string };
|
||||
prevPageLastRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
nextPageFirstRun?: { friendlyId: string; spanId: string; cursor: string };
|
||||
};
|
||||
|
||||
async function getRunsListFromTableState({
|
||||
tableStateParam,
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
}: {
|
||||
tableStateParam: string | null;
|
||||
organizationSlug: string;
|
||||
projectParam: string;
|
||||
envParam: string;
|
||||
runParam: string;
|
||||
userId: string;
|
||||
}): Promise<RunsListNavigation | null> {
|
||||
if (!tableStateParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
|
||||
|
||||
if (!project || !environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
|
||||
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
pageSize: 25, // Load enough runs to provide navigation context
|
||||
});
|
||||
|
||||
const runsList: RunsListNavigation = {
|
||||
runs: currentPageResult.runs,
|
||||
pagination: currentPageResult.pagination,
|
||||
};
|
||||
|
||||
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
|
||||
|
||||
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
|
||||
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
direction: "backward",
|
||||
pageSize: 1, // We only need the last run from the previous page
|
||||
});
|
||||
|
||||
if (prevPageResult.runs.length > 0) {
|
||||
runsList.prevPageLastRun = {
|
||||
friendlyId: prevPageResult.runs[0].friendlyId,
|
||||
spanId: prevPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.previous,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
|
||||
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
direction: "forward",
|
||||
pageSize: 1, // We only need the first run from the next page
|
||||
});
|
||||
|
||||
if (nextPageResult.runs.length > 0) {
|
||||
runsList.nextPageFirstRun = {
|
||||
friendlyId: nextPageResult.runs[0].friendlyId,
|
||||
spanId: nextPageResult.runs[0].spanId,
|
||||
cursor: currentPageResult.pagination.next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return runsList;
|
||||
} catch (error) {
|
||||
logger.error("Error loading runs list from tableState:", { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -142,7 +248,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const [error, result] = await tryCatch(
|
||||
presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
showDeletedLogs: !!impersonationId,
|
||||
projectSlug: projectParam,
|
||||
runFriendlyId: runParam,
|
||||
@@ -169,21 +274,31 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
|
||||
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
|
||||
|
||||
const runsList = await getRunsListFromTableState({
|
||||
tableStateParam: url.searchParams.get("tableState"),
|
||||
organizationSlug,
|
||||
projectParam,
|
||||
envParam,
|
||||
runParam,
|
||||
userId,
|
||||
});
|
||||
|
||||
return json({
|
||||
run: result.run,
|
||||
trace: result.trace,
|
||||
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
|
||||
maximumLiveReloadingSetting: result.maximumLiveReloadingSetting,
|
||||
resizable: {
|
||||
parent,
|
||||
tree,
|
||||
},
|
||||
runsList,
|
||||
});
|
||||
};
|
||||
|
||||
type LoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function Page() {
|
||||
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
|
||||
const { run, trace, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -191,16 +306,30 @@ export default function Page() {
|
||||
logCount: trace?.events.length ?? 0,
|
||||
isCompleted: run.completedAt !== null,
|
||||
});
|
||||
const { value } = useSearchParams();
|
||||
const tableState = decodeURIComponent(value("tableState") ?? "");
|
||||
const tableStateSearchParams = new URLSearchParams(tableState);
|
||||
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
|
||||
const tabParam = value("tab") ?? undefined;
|
||||
const spanParam = value("span") ?? undefined;
|
||||
|
||||
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableState, run, runsList, tabParam, useSpan: !!spanParam});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: v3RunsPath(organization, project, environment),
|
||||
to: v3RunsPath(organization, project, environment, filters),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={<CopyableText value={run.friendlyId} />}
|
||||
title={<>
|
||||
<CopyableText value={run.friendlyId} variant="text-below" className="font-mono px-0 py-0 pb-[2px]"/>
|
||||
{tableState && (<div className="flex">
|
||||
<PreviousRunButton to={previousRunPath} />
|
||||
<NextRunButton to={nextRunPath} />
|
||||
</div>)}
|
||||
</>}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
|
||||
<PageAccessories>
|
||||
@@ -276,14 +405,10 @@ export default function Page() {
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
) : (
|
||||
<NoLogsView
|
||||
run={run}
|
||||
trace={trace}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
resizable={resizable}
|
||||
/>
|
||||
)}
|
||||
</PageBody>
|
||||
@@ -291,7 +416,7 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting }: Pick<LoaderData, "run" | "trace" | "maximumLiveReloadingSetting">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -302,7 +427,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration } = trace;
|
||||
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
|
||||
trace;
|
||||
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
|
||||
|
||||
const changeToSpan = useDebounce((selectedSpan: string) => {
|
||||
@@ -324,6 +450,8 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const spanOverrides = selectedSpanId ? overridesBySpanId?.[selectedSpanId] : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1 overflow-hidden")}>
|
||||
<ResizablePanelGroup
|
||||
@@ -372,6 +500,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
<SpanView
|
||||
runParam={run.friendlyId}
|
||||
spanId={selectedSpanId}
|
||||
spanOverrides={spanOverrides as SpanOverride | undefined}
|
||||
closePanel={() => replaceSearchParam("span")}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
@@ -381,7 +510,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
);
|
||||
}
|
||||
|
||||
function NoLogsView({ run, resizable }: LoaderData) {
|
||||
function NoLogsView({ run }: Pick<LoaderData, "run">) {
|
||||
const plan = useCurrentPlan();
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -815,7 +944,6 @@ function TimelineView({
|
||||
scale,
|
||||
rootSpanStatus,
|
||||
rootStartedAt,
|
||||
parentRef,
|
||||
timelineScrollRef,
|
||||
virtualizer,
|
||||
events,
|
||||
@@ -1002,7 +1130,8 @@ function TimelineView({
|
||||
"-ml-[0.5px] h-[0.5625rem] w-px rounded-none",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={node.data.isPartial ? `${node.id}-${event.name}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1020,7 +1149,8 @@ function TimelineView({
|
||||
"-ml-[0.1562rem] size-[0.3125rem] rounded-full border bg-background-bright",
|
||||
eventBorderClassName(node.data)
|
||||
)}
|
||||
layoutId={`${node.id}-${event.name}`}
|
||||
layoutId={node.data.isPartial ? `${node.id}-${event.name}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1039,7 +1169,8 @@ function TimelineView({
|
||||
>
|
||||
<motion.div
|
||||
className={cn("h-px w-full", eventBackgroundClassName(node.data))}
|
||||
layoutId={`mark-${node.id}`}
|
||||
layoutId={node.data.isPartial ? `mark-${node.id}` : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
/>
|
||||
</Timeline.Span>
|
||||
) : null}
|
||||
@@ -1076,7 +1207,8 @@ function TimelineView({
|
||||
"-ml-0.5 size-3 rounded-full border-2 border-background-bright",
|
||||
eventBackgroundClassName(node.data)
|
||||
)}
|
||||
layoutId={node.id}
|
||||
layoutId={node.data.isPartial ? node.id : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
/>
|
||||
)}
|
||||
</Timeline.Point>
|
||||
@@ -1319,7 +1451,8 @@ function SpanWithDuration({
|
||||
fadeLeft ? "rounded-r-sm bg-gradient-to-r from-black/50 to-transparent" : "rounded-sm"
|
||||
)}
|
||||
style={{ backgroundSize: "20px 100%", backgroundRepeat: "no-repeat" }}
|
||||
layoutId={node.id}
|
||||
layoutId={node.data.isPartial ? node.id : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
>
|
||||
{node.data.isPartial && (
|
||||
<div
|
||||
@@ -1332,10 +1465,12 @@ function SpanWithDuration({
|
||||
"sticky left-0 z-10 transition-opacity group-hover:opacity-100",
|
||||
!showDuration && "opacity-0"
|
||||
)}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
>
|
||||
<motion.div
|
||||
className="whitespace-nowrap rounded-sm px-1 py-0.5 text-xxs text-text-bright text-shadow-custom"
|
||||
layout="position"
|
||||
layout={node.data.isPartial ? "position" : undefined}
|
||||
animate={!node.data.isPartial ? false : undefined}
|
||||
>
|
||||
{formatDurationMilliseconds(props.durationMs, {
|
||||
style: "short",
|
||||
@@ -1418,16 +1553,16 @@ function KeyboardShortcuts({
|
||||
expandAllBelowDepth,
|
||||
collapseAllBelowDepth,
|
||||
toggleExpandLevel,
|
||||
setShowDurations,
|
||||
}: {
|
||||
expandAllBelowDepth: (depth: number) => void;
|
||||
collapseAllBelowDepth: (depth: number) => void;
|
||||
toggleExpandLevel: (depth: number) => void;
|
||||
setShowDurations: (show: (show: boolean) => boolean) => void;
|
||||
setShowDurations?: (show: (show: boolean) => boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ArrowKeyShortcuts />
|
||||
<AdjacentRunsShortcuts />
|
||||
<ShortcutWithAction
|
||||
shortcut={{ key: "e" }}
|
||||
action={() => expandAllBelowDepth(0)}
|
||||
@@ -1444,6 +1579,16 @@ function KeyboardShortcuts({
|
||||
);
|
||||
}
|
||||
|
||||
function AdjacentRunsShortcuts() {
|
||||
return (<div className="flex items-center gap-0.5">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Adjacent runs
|
||||
</Paragraph>
|
||||
</div>);
|
||||
}
|
||||
|
||||
function ArrowKeyShortcuts() {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -1490,7 +1635,7 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>0</span>
|
||||
<span className="text-[0.75rem] text-text-dimmed">–</span>
|
||||
<span className="text-[0.65rem] text-text-dimmed">–</span>
|
||||
<span className={cn(variants.medium, "ml-0 mr-0")}>9</span>
|
||||
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
|
||||
Toggle level
|
||||
@@ -1522,3 +1667,127 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useAdjacentRunPaths({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
tableState,
|
||||
run,
|
||||
runsList,
|
||||
tabParam,
|
||||
useSpan
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
tableState: string;
|
||||
run: { friendlyId: string, spanId: string };
|
||||
runsList: RunsListNavigation | null;
|
||||
tabParam?: string;
|
||||
useSpan?: boolean;
|
||||
}): [string | null, string | null] {
|
||||
if (!runsList || runsList.runs.length === 0) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
|
||||
let previousRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const previousRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex > 0) {
|
||||
previousRun = runsList.runs[currentIndex - 1];
|
||||
} else if (runsList.prevPageLastRun) {
|
||||
previousRun = runsList.prevPageLastRun;
|
||||
// Update tableState with the new cursor for the previous page
|
||||
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
|
||||
previousRunTableState.set("direction", "backward");
|
||||
}
|
||||
|
||||
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
|
||||
let nextRun: { friendlyId: string; spanId: string } | null = null;
|
||||
const nextRunTableState = new URLSearchParams(tableState);
|
||||
if (currentIndex < runsList.runs.length - 1) {
|
||||
nextRun = runsList.runs[currentIndex + 1];
|
||||
} else if (runsList.nextPageFirstRun) {
|
||||
nextRun = runsList.nextPageFirstRun;
|
||||
// Update tableState with the new cursor for the next page
|
||||
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
|
||||
nextRunTableState.set("direction", "forward");
|
||||
}
|
||||
|
||||
const previousURLSearchParams = new URLSearchParams();
|
||||
previousURLSearchParams.set("tableState", previousRunTableState.toString());
|
||||
if (previousRun && useSpan) {
|
||||
previousURLSearchParams.set("span", previousRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
previousURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const previousRunPath = previousRun
|
||||
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
|
||||
: null;
|
||||
|
||||
const nextURLSearchParams = new URLSearchParams();
|
||||
nextURLSearchParams.set("tableState", nextRunTableState.toString());
|
||||
if (nextRun && useSpan) {
|
||||
nextURLSearchParams.set("span", nextRun.spanId);
|
||||
}
|
||||
if (tabParam && useSpan) {
|
||||
nextURLSearchParams.set("tab", tabParam);
|
||||
}
|
||||
const nextRunPath = nextRun
|
||||
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
|
||||
: null;
|
||||
|
||||
return [previousRunPath, nextRunPath];
|
||||
}
|
||||
|
||||
|
||||
function PreviousRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={ChevronUpIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "[" }}
|
||||
tooltip="Previous Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextRunButton({ to }: { to: string | null }) {
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={to ? to : '#'}
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon={ChevronDownIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
|
||||
!to && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !to && e.preventDefault()}
|
||||
shortcut={{ key: "]" }}
|
||||
tooltip="Next Run"
|
||||
disabled={!to}
|
||||
replace
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -55,6 +55,7 @@ import {
|
||||
v3CreateBulkActionPath,
|
||||
v3ProjectPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
|
||||
@@ -235,7 +236,13 @@ function RunsList({
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
<RunTaskInstructions
|
||||
task={
|
||||
list.filters.tasks.length === 1
|
||||
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
|
||||
@@ -291,6 +298,7 @@ function RunsList({
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -339,7 +347,7 @@ function CreateFirstTaskInstructions() {
|
||||
);
|
||||
}
|
||||
|
||||
function RunTaskInstructions() {
|
||||
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
@@ -352,7 +360,11 @@ function RunTaskInstructions() {
|
||||
Perform a test run with a payload directly from the dashboard.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
to={
|
||||
task
|
||||
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
|
||||
: v3TestPath(organization, project, environment)
|
||||
}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-lime-500"
|
||||
|
||||
+1
@@ -318,6 +318,7 @@ export default function Page() {
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
|
||||
+79
-672
@@ -1,34 +1,19 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
type MetaFunction,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
useNavigate,
|
||||
} from "@remix-run/react";
|
||||
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
@@ -49,35 +34,16 @@ import {
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
getSession,
|
||||
commitSession,
|
||||
} from "~/models/message.server";
|
||||
import { ProjectSettingsService } from "~/services/projectSettings.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
organizationPath,
|
||||
v3ProjectPath,
|
||||
githubAppInstallPath,
|
||||
EnvironmentParamSchema,
|
||||
v3ProjectSettingsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { organizationPath, v3ProjectPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { type BranchTrackingConfig } from "~/v3/github";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { GitBranchIcon } from "lucide-react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
|
||||
import { type BuildSettings } from "~/v3/buildSettings";
|
||||
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -123,40 +89,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const { gitHubApp, buildSettings } = resultOrFail.value;
|
||||
|
||||
const session = await getSession(request.headers.get("Cookie"));
|
||||
const openGitHubRepoConnectionModal = session.get("gitHubAppInstalled") === true;
|
||||
const headers = new Headers({
|
||||
"Set-Cookie": await commitSession(session),
|
||||
return typedjson({
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
buildSettings,
|
||||
});
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
githubAppEnabled: gitHubApp.enabled,
|
||||
githubAppInstallations: gitHubApp.installations,
|
||||
connectedGithubRepository: gitHubApp.connectedRepository,
|
||||
openGitHubRepoConnectionModal,
|
||||
buildSettings,
|
||||
},
|
||||
{ headers }
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectGitHubRepoFormSchema = z.object({
|
||||
action: z.literal("connect-repo"),
|
||||
installationId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
});
|
||||
|
||||
const UpdateGitSettingsFormSchema = z.object({
|
||||
action: z.literal("update-git-settings"),
|
||||
productionBranch: z.string().trim().optional(),
|
||||
stagingBranch: z.string().trim().optional(),
|
||||
previewDeploymentsEnabled: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
const UpdateBuildSettingsFormSchema = z.object({
|
||||
action: z.literal("update-build-settings"),
|
||||
triggerConfigFilePath: z
|
||||
@@ -167,14 +105,6 @@ const UpdateBuildSettingsFormSchema = z.object({
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Config file path must not exceed 255 characters",
|
||||
}),
|
||||
installDirectory: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
|
||||
.refine((val) => !val || val.length <= 255, {
|
||||
message: "Install directory must not exceed 255 characters",
|
||||
}),
|
||||
installCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -185,6 +115,20 @@ const UpdateBuildSettingsFormSchema = z.object({
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Install command must not exceed 500 characters",
|
||||
}),
|
||||
preBuildCommand: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((val) => !val || !val.includes("\n"), {
|
||||
message: "Pre-build command must be a single line",
|
||||
})
|
||||
.refine((val) => !val || val.length <= 500, {
|
||||
message: "Pre-build command must not exceed 500 characters",
|
||||
}),
|
||||
useNativeBuildServer: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "on"),
|
||||
});
|
||||
|
||||
type UpdateBuildSettingsFormSchema = z.infer<typeof UpdateBuildSettingsFormSchema>;
|
||||
@@ -220,12 +164,7 @@ export function createSchema(
|
||||
}
|
||||
}),
|
||||
}),
|
||||
ConnectGitHubRepoFormSchema,
|
||||
UpdateGitSettingsFormSchema,
|
||||
UpdateBuildSettingsFormSchema,
|
||||
z.object({
|
||||
action: z.literal("disconnect-repo"),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -260,7 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
|
||||
}
|
||||
|
||||
const { projectId, organizationId } = membershipResultOrFail.value;
|
||||
const { projectId } = membershipResultOrFail.value;
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
@@ -316,108 +255,15 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"Project deleted"
|
||||
);
|
||||
}
|
||||
case "disconnect-repo": {
|
||||
const resultOrFail = await projectSettingsService.disconnectGitHubRepo(projectId);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to disconnect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to disconnect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "GitHub repository disconnected successfully");
|
||||
}
|
||||
case "update-git-settings": {
|
||||
const { productionBranch, stagingBranch, previewDeploymentsEnabled } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateGitSettings(
|
||||
projectId,
|
||||
productionBranch,
|
||||
stagingBranch,
|
||||
previewDeploymentsEnabled
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "github_app_not_enabled": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub app is not enabled");
|
||||
}
|
||||
case "connected_gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Connected GitHub repository not found");
|
||||
}
|
||||
case "production_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Production tracking branch not found");
|
||||
}
|
||||
case "staging_tracking_branch_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "Staging tracking branch not found");
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to update Git settings", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to update Git settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirectBackWithSuccessMessage(request, "Git settings updated successfully");
|
||||
}
|
||||
case "connect-repo": {
|
||||
const { repositoryId, installationId } = submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.connectGitHubRepo(
|
||||
projectId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
installationId
|
||||
);
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
switch (resultOrFail.error.type) {
|
||||
case "gh_repository_not_found": {
|
||||
return redirectBackWithErrorMessage(request, "GitHub repository not found");
|
||||
}
|
||||
case "project_already_has_connected_repository": {
|
||||
return redirectBackWithErrorMessage(
|
||||
request,
|
||||
"Project already has a connected repository"
|
||||
);
|
||||
}
|
||||
case "other":
|
||||
default: {
|
||||
resultOrFail.error.type satisfies "other";
|
||||
|
||||
logger.error("Failed to connect GitHub repository", {
|
||||
error: resultOrFail.error,
|
||||
});
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect GitHub repository");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
...submission,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
case "update-build-settings": {
|
||||
const { installDirectory, installCommand, triggerConfigFilePath } = submission.value;
|
||||
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
|
||||
submission.value;
|
||||
|
||||
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
|
||||
installDirectory: installDirectory || undefined,
|
||||
installCommand: installCommand || undefined,
|
||||
preBuildCommand: preBuildCommand || undefined,
|
||||
triggerConfigFilePath: triggerConfigFilePath || undefined,
|
||||
useNativeBuildServer: useNativeBuildServer,
|
||||
});
|
||||
|
||||
if (resultOrFail.isErr()) {
|
||||
@@ -444,13 +290,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
githubAppInstallations,
|
||||
connectedGithubRepository,
|
||||
githubAppEnabled,
|
||||
openGitHubRepoConnectionModal,
|
||||
buildSettings,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
const { githubAppEnabled, buildSettings } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const environment = useEnvironment();
|
||||
@@ -576,17 +416,12 @@ export default function Page() {
|
||||
<div>
|
||||
<Header2 spacing>Git settings</Header2>
|
||||
<div className="w-full rounded-sm border border-grid-dimmed p-4">
|
||||
{connectedGithubRepository ? (
|
||||
<ConnectedGitHubRepoForm connectedGitHubRepo={connectedGithubRepository} />
|
||||
) : (
|
||||
<GitHubConnectionPrompt
|
||||
gitHubAppInstallations={githubAppInstallations ?? []}
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
openGitHubRepoConnectionModal={openGitHubRepoConnectionModal}
|
||||
/>
|
||||
)}
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -646,480 +481,24 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
type GitHubRepository = {
|
||||
id: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
type GitHubAppInstallation = {
|
||||
id: string;
|
||||
appInstallationId: bigint;
|
||||
targetType: string;
|
||||
accountHandle: string;
|
||||
repositories: GitHubRepository[];
|
||||
};
|
||||
|
||||
function ConnectGitHubRepoModal({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
open = false,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
open?: boolean;
|
||||
}) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(open);
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [selectedInstallation, setSelectedInstallation] = useState<
|
||||
GitHubAppInstallation | undefined
|
||||
>(gitHubAppInstallations.at(0));
|
||||
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isConnectRepositoryLoading =
|
||||
navigation.formData?.get("action") === "connect-repo" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const [form, { installationId, repositoryId }] = useForm({
|
||||
id: "connect-repo",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: ConnectGitHubRepoFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSubmission && "success" in lastSubmission && lastSubmission.success === true) {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}, [lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant={"secondary/medium"} LeadingIcon={OctoKitty}>
|
||||
Connect GitHub repo
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Connect GitHub repository</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<Form method="post" {...form.props} className="w-full">
|
||||
<Paragraph className="mb-3">
|
||||
Choose a GitHub repository to connect to your project.
|
||||
</Paragraph>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={installationId.id}>Account</Label>
|
||||
<Select
|
||||
name={installationId.name}
|
||||
id={installationId.id}
|
||||
value={selectedInstallation?.id}
|
||||
defaultValue={gitHubAppInstallations.at(0)?.id}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const installation = gitHubAppInstallations.find((i) => i.id === value);
|
||||
setSelectedInstallation(installation);
|
||||
setSelectedRepository(undefined);
|
||||
}}
|
||||
items={gitHubAppInstallations}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select account"
|
||||
dropdownIcon
|
||||
text={selectedInstallation ? selectedInstallation.accountHandle : undefined}
|
||||
>
|
||||
{[
|
||||
...gitHubAppInstallations.map((installation) => (
|
||||
<SelectItem
|
||||
key={installation.id}
|
||||
value={installation.id}
|
||||
icon={<OctoKitty className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
{installation.accountHandle}
|
||||
</SelectItem>
|
||||
)),
|
||||
<SelectItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(
|
||||
githubAppInstallPath(
|
||||
organizationSlug,
|
||||
v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)
|
||||
)
|
||||
);
|
||||
}}
|
||||
key="new-account"
|
||||
icon={<PlusIcon className="size-3 text-text-dimmed" />}
|
||||
>
|
||||
Add account
|
||||
</SelectItem>,
|
||||
]}
|
||||
</Select>
|
||||
<FormError id={installationId.errorId}>{installationId.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label htmlFor={repositoryId.id}>Repository</Label>
|
||||
<Select
|
||||
name={repositoryId.name}
|
||||
id={repositoryId.id}
|
||||
value={selectedRepository ? selectedRepository.id : undefined}
|
||||
setValue={(value) => {
|
||||
if (Array.isArray(value)) return;
|
||||
const repository = selectedInstallation?.repositories.find(
|
||||
(r) => r.id === value
|
||||
);
|
||||
setSelectedRepository(repository);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select repository"
|
||||
heading="Filter repositories"
|
||||
dropdownIcon
|
||||
items={selectedInstallation?.repositories ?? []}
|
||||
filter={{ keys: ["name"] }}
|
||||
disabled={!selectedInstallation || selectedInstallation.repositories.length === 0}
|
||||
text={selectedRepository ? selectedRepository.name : null}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
{repo.name}
|
||||
{repo.private && <LockClosedIcon className="size-3 text-text-dimmed" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint className={cn("invisible", selectedInstallation && "visible")}>
|
||||
Configure repository access in{" "}
|
||||
<TextLink
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
to={`https://github.com/settings/installations/${selectedInstallation?.appInstallationId}`}
|
||||
>
|
||||
GitHub
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
<FormError id={repositoryId.errorId}>{repositoryId.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="connect-repo"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isConnectRepositoryLoading}
|
||||
>
|
||||
Connect repository
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubConnectionPrompt({
|
||||
gitHubAppInstallations,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
openGitHubRepoConnectionModal = false,
|
||||
}: {
|
||||
gitHubAppInstallations: GitHubAppInstallation[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
openGitHubRepoConnectionModal?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
{gitHubAppInstallations.length === 0 && (
|
||||
<LinkButton
|
||||
to={githubAppInstallPath(
|
||||
organizationSlug,
|
||||
v3ProjectSettingsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
)
|
||||
)}
|
||||
variant={"secondary/medium"}
|
||||
LeadingIcon={OctoKitty}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
)}
|
||||
{gitHubAppInstallations.length !== 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ConnectGitHubRepoModal
|
||||
gitHubAppInstallations={gitHubAppInstallations}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
open={openGitHubRepoConnectionModal}
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-xs text-text-dimmed">
|
||||
<CheckCircleIcon className="size-4 text-success" /> GitHub app is installed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Hint>Connect your GitHub repository to automatically deploy your changes.</Hint>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
type ConnectedGitHubRepo = {
|
||||
branchTracking: BranchTrackingConfig | undefined;
|
||||
previewDeploymentsEnabled: boolean;
|
||||
createdAt: Date;
|
||||
repository: GitHubRepository;
|
||||
};
|
||||
|
||||
function ConnectedGitHubRepoForm({
|
||||
connectedGitHubRepo,
|
||||
}: {
|
||||
connectedGitHubRepo: ConnectedGitHubRepo;
|
||||
}) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasGitSettingsChanges, setHasGitSettingsChanges] = useState(false);
|
||||
const [gitSettingsValues, setGitSettingsValues] = useState({
|
||||
productionBranch: connectedGitHubRepo.branchTracking?.prod?.branch || "",
|
||||
stagingBranch: connectedGitHubRepo.branchTracking?.staging?.branch || "",
|
||||
previewDeploymentsEnabled: connectedGitHubRepo.previewDeploymentsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
gitSettingsValues.productionBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.prod?.branch || "") ||
|
||||
gitSettingsValues.stagingBranch !==
|
||||
(connectedGitHubRepo.branchTracking?.staging?.branch || "") ||
|
||||
gitSettingsValues.previewDeploymentsEnabled !== connectedGitHubRepo.previewDeploymentsEnabled;
|
||||
setHasGitSettingsChanges(hasChanges);
|
||||
}, [gitSettingsValues, connectedGitHubRepo]);
|
||||
|
||||
const [gitSettingsForm, fields] = useForm({
|
||||
id: "update-git-settings",
|
||||
lastSubmission: lastSubmission,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: UpdateGitSettingsFormSchema,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isGitSettingsLoading =
|
||||
navigation.formData?.get("action") === "update-git-settings" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between rounded-sm border bg-grid-dimmed p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<OctoKitty className="size-4" />
|
||||
<a
|
||||
href={connectedGitHubRepo.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-bright hover:underline"
|
||||
>
|
||||
{connectedGitHubRepo.repository.fullName}
|
||||
</a>
|
||||
{connectedGitHubRepo.repository.private && (
|
||||
<LockClosedIcon className="size-3 text-text-dimmed" />
|
||||
)}
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime
|
||||
date={connectedGitHubRepo.createdAt}
|
||||
includeTime={false}
|
||||
includeSeconds={false}
|
||||
showTimezone={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small">Disconnect</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Disconnect GitHub repository</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph className="mb-1">
|
||||
Are you sure you want to disconnect{" "}
|
||||
<span className="font-semibold">{connectedGitHubRepo.repository.fullName}</span>?
|
||||
This will stop automatic deployments from GitHub.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post">
|
||||
<input type="hidden" name="action" value="disconnect-repo" />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Disconnect repository
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Form method="post" {...gitSettingsForm.props}>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Hint>
|
||||
Every commit on the selected tracking branch creates a deployment in the corresponding
|
||||
environment.
|
||||
</Hint>
|
||||
<div className="grid grid-cols-[120px_1fr] gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PRODUCTION" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PRODUCTION" })}`}>
|
||||
{environmentFullTitle({ type: "PRODUCTION" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.productionBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.prod?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
productionBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "STAGING" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "STAGING" })}`}>
|
||||
{environmentFullTitle({ type: "STAGING" })}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
{...conform.input(fields.stagingBranch, { type: "text" })}
|
||||
defaultValue={connectedGitHubRepo.branchTracking?.staging?.branch}
|
||||
placeholder="none"
|
||||
variant="tertiary"
|
||||
className="font-mono"
|
||||
icon={GitBranchIcon}
|
||||
onChange={(e) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
stagingBranch: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: "PREVIEW" }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: "PREVIEW" })}`}>
|
||||
{environmentFullTitle({ type: "PREVIEW" })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
name="previewDeploymentsEnabled"
|
||||
defaultChecked={connectedGitHubRepo.previewDeploymentsEnabled}
|
||||
variant="small"
|
||||
label="create preview deployments for pull requests"
|
||||
labelPosition="right"
|
||||
onCheckedChange={(checked) => {
|
||||
setGitSettingsValues((prev) => ({
|
||||
...prev,
|
||||
previewDeploymentsEnabled: checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormError>{fields.productionBranch?.error}</FormError>
|
||||
<FormError>{fields.stagingBranch?.error}</FormError>
|
||||
<FormError>{fields.previewDeploymentsEnabled?.error}</FormError>
|
||||
<FormError>{gitSettingsForm.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="update-git-settings"
|
||||
variant="secondary/small"
|
||||
disabled={isGitSettingsLoading || !hasGitSettingsChanges}
|
||||
LeadingIcon={isGitSettingsLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
|
||||
const lastSubmission = useActionData() as any;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
|
||||
const [buildSettingsValues, setBuildSettingsValues] = useState({
|
||||
installDirectory: buildSettings?.installDirectory || "",
|
||||
preBuildCommand: buildSettings?.preBuildCommand || "",
|
||||
installCommand: buildSettings?.installCommand || "",
|
||||
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
|
||||
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasChanges =
|
||||
buildSettingsValues.installDirectory !== (buildSettings?.installDirectory || "") ||
|
||||
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
|
||||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "");
|
||||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
|
||||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
|
||||
setHasBuildSettingsChanges(hasChanges);
|
||||
}, [buildSettingsValues, buildSettings]);
|
||||
|
||||
@@ -1167,7 +546,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
<Input
|
||||
{...conform.input(fields.installCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.installCommand || ""}
|
||||
placeholder="e.g., `npm install`, or `bun install`"
|
||||
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
@@ -1175,27 +554,55 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>Command to install your project dependencies. Auto-detected by default.</Hint>
|
||||
<Hint>
|
||||
Command to install your project dependencies. This will be run from the root directory
|
||||
of your repo. Auto-detected by default.
|
||||
</Hint>
|
||||
<FormError id={fields.installCommand.errorId}>{fields.installCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor={fields.installDirectory.id}>Install directory</Label>
|
||||
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
|
||||
<Input
|
||||
{...conform.input(fields.installDirectory, { type: "text" })}
|
||||
defaultValue={buildSettings?.installDirectory || ""}
|
||||
placeholder=""
|
||||
{...conform.input(fields.preBuildCommand, { type: "text" })}
|
||||
defaultValue={buildSettings?.preBuildCommand || ""}
|
||||
placeholder="e.g., `npm run prisma:generate`"
|
||||
onChange={(e) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
installDirectory: e.target.value,
|
||||
preBuildCommand: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>The directory where the install command is run in. Auto-detected by default.</Hint>
|
||||
<FormError id={fields.installDirectory.errorId}>
|
||||
{fields.installDirectory.error}
|
||||
</FormError>
|
||||
<Hint>
|
||||
Any command that needs to run before we build and deploy your project. This will be run
|
||||
from the root directory of your repo.
|
||||
</Hint>
|
||||
<FormError id={fields.preBuildCommand.errorId}>{fields.preBuildCommand.error}</FormError>
|
||||
</InputGroup>
|
||||
<div className="border-t border-grid-dimmed pt-4">
|
||||
<InputGroup>
|
||||
<CheckboxWithLabel
|
||||
id={fields.useNativeBuildServer.id}
|
||||
{...conform.input(fields.useNativeBuildServer, { type: "checkbox" })}
|
||||
label="Use native build server"
|
||||
variant="simple/small"
|
||||
defaultChecked={buildSettings?.useNativeBuildServer || false}
|
||||
onChange={(isChecked) => {
|
||||
setBuildSettingsValues((prev) => ({
|
||||
...prev,
|
||||
useNativeBuildServer: isChecked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Hint>
|
||||
Native build server builds do not rely on external build providers and will become the
|
||||
default in the future. Version 4.2.0 or newer is required.
|
||||
</Hint>
|
||||
<FormError id={fields.useNativeBuildServer.errorId}>
|
||||
{fields.useNativeBuildServer.error}
|
||||
</FormError>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<FormError>{buildSettingsForm.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
|
||||
+3
-1
@@ -73,6 +73,8 @@ import { DeleteTaskRunTemplateData, RunTemplateData } from "~/v3/taskRunTemplate
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
|
||||
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
|
||||
|
||||
@@ -96,7 +98,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new TestTaskPresenter();
|
||||
const presenter = new TestTaskPresenter($replica, clickhouseClient);
|
||||
try {
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ export default function Page() {
|
||||
runs={waitpoint.connectedRuns}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -47,7 +47,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -55,8 +55,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
@@ -181,7 +181,7 @@ export default function Page() {
|
||||
const fieldValues = useRef<string[]>(alerts.emails);
|
||||
const emailFields = useFieldList(form.ref, { ...emails, defaultValue: alerts.emails });
|
||||
|
||||
const checkboxLevels = [0.75, 0.9, 1.0];
|
||||
const checkboxLevels = [0.75, 0.9, 1.0, 2.0, 5.0];
|
||||
|
||||
useEffect(() => {
|
||||
if (alerts.emails.length > 0) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -41,8 +41,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
throw new Response(null, { status: 404, statusText: "Plans not found" });
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
@@ -83,6 +83,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
export default function ChoosePlanPage() {
|
||||
const {
|
||||
plans,
|
||||
addOnPricing,
|
||||
v3Subscription,
|
||||
organizationSlug,
|
||||
periodStart,
|
||||
@@ -141,6 +142,7 @@ export default function ChoosePlanPage() {
|
||||
<div>
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
concurrencyAddOnPricing={addOnPricing.concurrency}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan={false}
|
||||
|
||||
@@ -46,7 +46,7 @@ export const meta: MetaFunction = () => {
|
||||
};
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
@@ -54,8 +54,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
return redirect(organizationPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { BackgroundWrapper } from "~/components/BackgroundWrapper";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
@@ -21,10 +22,11 @@ import { Label } from "~/components/primitives/Label";
|
||||
import { ButtonSpinner } from "~/components/primitives/Spinner";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject, ExceededProjectLimitError } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
newProjectPath,
|
||||
OrganizationParamsSchema,
|
||||
organizationPath,
|
||||
selectPlanPath,
|
||||
@@ -114,8 +116,29 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
request,
|
||||
`${submission.value.projectName} created`
|
||||
);
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
} catch (error) {
|
||||
if (error instanceof ExceededProjectLimitError) {
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error.message,
|
||||
{
|
||||
title: "Failed to create project",
|
||||
action: {
|
||||
label: "Request more projects",
|
||||
variant: "secondary/small",
|
||||
action: { type: "help", feedbackType: "help" },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
error instanceof Error ? error.message : "Something went wrong",
|
||||
{ ephemeral: false }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,6 +214,7 @@ export default function Page() {
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
<Feedback button={<></>} />
|
||||
</MainCenteredContainer>
|
||||
</BackgroundWrapper>
|
||||
</AppContainer>
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { plans, v3Subscription, organizationSlug, periodEnd } =
|
||||
const { plans, v3Subscription, organizationSlug, periodEnd, addOnPricing } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
@@ -57,6 +57,7 @@ export default function ChoosePlanPage() {
|
||||
<div className="w-full rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||||
<PricingPlans
|
||||
plans={plans}
|
||||
concurrencyAddOnPricing={addOnPricing.concurrency}
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan
|
||||
|
||||
@@ -3,8 +3,8 @@ import { parse } from "@conform-to/zod";
|
||||
import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, MetaFunction, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
@@ -16,7 +16,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
@@ -36,8 +35,8 @@ import {
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
CreatedPersonalAccessToken,
|
||||
ObfuscatedPersonalAccessToken,
|
||||
type CreatedPersonalAccessToken,
|
||||
type ObfuscatedPersonalAccessToken,
|
||||
createPersonalAccessToken,
|
||||
getValidPersonalAccessTokens,
|
||||
revokePersonalAccessToken,
|
||||
@@ -53,7 +52,7 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
try {
|
||||
@@ -113,7 +112,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
case "revoke": {
|
||||
try {
|
||||
await revokePersonalAccessToken(submission.value.tokenId);
|
||||
await revokePersonalAccessToken(submission.value.tokenId, userId);
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
personalAccessTokensPath(),
|
||||
@@ -125,6 +124,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
default: {
|
||||
submission.value satisfies never;
|
||||
return json({ errors: { body: "Invalid action" } }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,17 @@ export default function AdminDashboardRoute() {
|
||||
<TableCell isSticky={true}>
|
||||
<Form method="post" reloadDocument>
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
name="action"
|
||||
value="impersonate"
|
||||
className="mr-2"
|
||||
variant="tertiary/small"
|
||||
shortcut={
|
||||
users.length === 1
|
||||
? { modifiers: ["mod"], key: "enter", enabledOnInputElements: true }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Impersonate
|
||||
</Button>
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import pMap from "p-map";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
const BodySchema = z.object({
|
||||
dryRun: z.boolean().default(true),
|
||||
queues: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.parse(params);
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: parsedParams.environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
orgMember: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engineVersion = await determineEngineVersion({ environment });
|
||||
|
||||
if (engineVersion === "V1") {
|
||||
return json({ error: "Engine version is V1" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsedBody = BodySchema.parse(body);
|
||||
|
||||
const queues = await $replica.taskQueue.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: "V2",
|
||||
name: parsedBody.queues.length > 0 ? { in: parsedBody.queues } : undefined,
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
orderBy: {
|
||||
orderableName: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const repairEnvironmentResults = await engine.repairEnvironment(environment, parsedBody.dryRun);
|
||||
|
||||
const repairResults = await pMap(
|
||||
queues,
|
||||
async (queue) => {
|
||||
const repair = await engine.repairQueue(
|
||||
environment,
|
||||
queue.name,
|
||||
parsedBody.dryRun,
|
||||
repairEnvironmentResults.runIds
|
||||
);
|
||||
|
||||
return {
|
||||
queue: queue.name,
|
||||
...repair,
|
||||
};
|
||||
},
|
||||
{ concurrency: 5 }
|
||||
);
|
||||
|
||||
return json({ environment: repairEnvironmentResults, queues: repairResults });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { determineEngineVersion } from "~/v3/engineVersion.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
environmentId: z.string(),
|
||||
});
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
verbose: z.string().default("0"),
|
||||
page: z.coerce.number().optional(),
|
||||
per_page: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: authenticationResult.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.parse(params);
|
||||
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: parsedParams.environmentId,
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
orgMember: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const engineVersion = await determineEngineVersion({ environment });
|
||||
|
||||
if (engineVersion === "V1") {
|
||||
return json({ error: "Engine version is V1" }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchParams = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
const page = searchParams.page ?? 1;
|
||||
const perPage = searchParams.per_page ?? 50;
|
||||
|
||||
const queues = await $replica.taskQueue.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: "V2",
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
type: true,
|
||||
paused: true,
|
||||
},
|
||||
orderBy: {
|
||||
orderableName: "asc",
|
||||
},
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
});
|
||||
|
||||
const report = await engine.generateEnvironmentReport(
|
||||
environment,
|
||||
queues,
|
||||
searchParams.verbose === "1"
|
||||
);
|
||||
|
||||
return json(report);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
limit = body.development;
|
||||
break;
|
||||
}
|
||||
case "PREVIEW":
|
||||
case "STAGING": {
|
||||
limit = body.staging;
|
||||
break;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user