Compare commits

...

98 Commits

Author SHA1 Message Date
Eric Allam 6fcfe6b62b Changeset: prerelease next packages 2023-03-13 12:26:47 +00:00
James Ritchie c593132663 Reimplemented the impersonation banner 2023-03-13 12:23:45 +00:00
Eric Allam ff7bdd7619 Add cloud related env vars to staging env 2023-03-13 11:41:40 +00:00
Eric Allam c229fa31fc Setup posthog site feedback widget 2023-03-13 11:28:22 +00:00
James Ritchie 632f76cbb9 Added the Org nav to the Repo Coming Soon page 2023-03-13 11:27:12 +00:00
Eric Allam 5dae509aa9 Fixed up a bunch of nav issues 2023-03-13 11:22:54 +00:00
James Ritchie 1b902dabfa Updated the repository coming soon page 2023-03-13 11:01:38 +00:00
Eric Allam a1043a3b28 Added coming soon page for the cloud hosting 2023-03-13 10:35:19 +00:00
James Ritchie 22c682bf4c Added a new Org side menu 2023-03-13 10:29:04 +00:00
Eric Allam 0b67b51ae4 Fix ESM error by dynamically importing ESM packages (chalk, terminal-link, etc.) 2023-03-12 20:35:57 +00:00
Eric Allam 1673d4527a Added KV store feature 2023-03-12 20:12:18 +00:00
James Ritchie 5e762a9cbf Moved request integration button to top right of page 2023-03-12 10:26:27 +00:00
Eric Allam 406be0107e A couple side menu fixes 2023-03-11 21:16:30 +00:00
James Ritchie 4ab4174cb2 Improved the layout of the settings page 2023-03-11 18:35:54 +00:00
James Ritchie 78e6ed9bfc Added some messaging for blank states 2023-03-11 18:27:31 +00:00
James Ritchie f813e59b0c Improved the state if you haven’t granted access to github yet 2023-03-11 18:16:26 +00:00
James Ritchie 2992a855f9 Added active states to the collapsed side menu 2023-03-11 17:53:28 +00:00
James Ritchie f172a0ee1a Added titles on hover for the compact menu 2023-03-11 17:38:15 +00:00
James Ritchie 3c74e4f5f6 organized imports 2023-03-11 17:38:04 +00:00
James Ritchie bf4eefe4fd Long titles now ellipsis in the menu header 2023-03-11 17:13:21 +00:00
James Ritchie 2e81d6fe20 Fixed page layouts when they have the secondary nav in them 2023-03-11 17:07:43 +00:00
James Ritchie 77f243a14a Fixed the app layout width 2023-03-11 16:37:46 +00:00
James Ritchie 4245c21248 Added a section title to the side menu 2023-03-11 16:37:35 +00:00
James Ritchie e6d276de03 Added a title to the top of the nav 2023-03-11 16:13:26 +00:00
Eric Allam 0e3c3a78d7 Add single run API endpoint 2023-03-11 15:39:34 +00:00
Eric Allam fa28005f40 Move around stuff for the new nav structure 2023-03-11 15:33:03 +00:00
Eric Allam c109a9f31c Add runs JSON endpoint 2023-03-10 18:38:46 +00:00
James Ritchie c1dd5e92fe Moved the github connect button to top right 2023-03-10 18:25:26 +00:00
James Ritchie 441183975c Updated the select repo page 2023-03-10 18:23:30 +00:00
Eric Allam 0f9c0a8d06 Improve the select repo experience a little bit 2023-03-10 17:58:32 +00:00
Eric Allam 5beba8f12e Fixes an issue where the trigger workflow ttl guard was preventing workflow runs from resuming after being disconnected 2023-03-10 17:58:32 +00:00
James Ritchie 9b7102605f Fixed the create new workflow page layout 2023-03-10 17:10:12 +00:00
James Ritchie 0618cd0d7a Improved the visibility of the copy CLI text button 2023-03-10 17:09:59 +00:00
James Ritchie bc7f11fb57 Improved the API Key messaging 2023-03-10 17:06:15 +00:00
James Ritchie 19d731eebb removed unused imports 2023-03-10 16:53:32 +00:00
James Ritchie e2b183bd96 Added better buttons to the header 2023-03-10 16:53:11 +00:00
James Ritchie 80709c5415 Improved button focus states 2023-03-10 16:52:54 +00:00
James Ritchie 22e3c7f5f2 Fixed height of AppBody 2023-03-10 16:38:37 +00:00
James Ritchie 843231c410 WIP Improving the side navigation for the app 2023-03-10 16:13:40 +00:00
James Ritchie 1a33bdd3ae Updated the side menu - removed redundant items 2023-03-10 16:13:40 +00:00
Eric Allam c0300c6bec Deal with project settings and environment vars, and disabling projects 2023-03-10 15:43:40 +00:00
James Ritchie bffa5ef0e2 Improvements to the Logs page 2023-03-09 18:52:31 +00:00
James Ritchie 4e95232d59 improved the markup for the deploy page info panel 2023-03-09 18:40:32 +00:00
James Ritchie ca64356f12 Select repo now has a state for ‘relevant’ 2023-03-09 18:38:25 +00:00
James Ritchie c2c1b0feb4 Improvement to the repo page 2023-03-09 16:30:06 +00:00
James Ritchie 57afaf1995 Improvements to the repo details panel 2023-03-09 16:05:27 +00:00
Eric Allam 4c965ed5a2 Fixed issue with pending project endlessly revalidating 2023-03-09 16:01:03 +00:00
James Ritchie 0462141e1b Improvements to the Overview and deploy pages 2023-03-09 15:52:29 +00:00
Eric Allam ce39397c57 Better format log dates 2023-03-09 15:39:07 +00:00
Eric Allam 55cb81f7af Implement the log page along with the log links 2023-03-09 15:31:38 +00:00
James Ritchie f7a4e69025 status icon improvements 2023-03-09 14:23:38 +00:00
Eric Allam 55ac0019b7 Couple tweaks to the logs 2023-03-09 14:22:35 +00:00
Eric Allam b87a3dd2ba More logging work and buttons for stoppping or cancelling a deployment 2023-03-09 14:02:03 +00:00
Eric Allam 41c751c081 Changing on logs are polled, and now polling for build logs as well 2023-03-09 14:01:28 +00:00
Eric Allam 1f34a22bd8 Update how we poll for logs and add build logs back in 2023-03-09 14:01:28 +00:00
James Ritchie 10ef03eee0 Coloured the log red if it’s an error 2023-03-09 13:17:33 +00:00
James Ritchie bef6de13db Added a new logs table 2023-03-09 13:10:29 +00:00
D-K-P bd356663cc Removed .dev from Acme 2023-03-09 11:53:01 +00:00
James Ritchie 1670c3e19b Compressed the info in the overview panel 2023-03-09 11:03:35 +00:00
James Ritchie aa35555356 Added a Stop button 2023-03-09 10:48:18 +00:00
James Ritchie 1520be20c4 Added a view build logs with disabled property 2023-03-09 10:33:03 +00:00
James Ritchie 9cd49a40d5 Show spinner if status is deploying, pending, building. Also added live reload indicator 2023-03-09 10:17:21 +00:00
James Ritchie d27f11e26c More status colour updates 2023-03-09 09:51:17 +00:00
James Ritchie 9aff64183b Updated status colours 2023-03-09 09:48:49 +00:00
James Ritchie 5e32c8498b New layout for the deployment page 2023-03-09 09:42:11 +00:00
Eric Allam bd2f2d442d Implement the select repo grouping by gtihub account 2023-03-09 09:11:27 +00:00
James Ritchie 328ff206d7 Updated deployListStatus 2023-03-09 09:07:25 +00:00
James Ritchie 774a0fe9e3 Added a Header 2023-03-08 20:52:23 +00:00
James Ritchie 3eb76739cc Added type=submit 2023-03-08 20:52:05 +00:00
James Ritchie f00f6ae1bd Improvements to the deployment overview page 2023-03-08 20:32:52 +00:00
James Ritchie 7798c0166d Added a set of icons, loading indicators and titles to use for the deployment status 2023-03-08 20:28:02 +00:00
James Ritchie 9c63687d56 Initial clean up of the deploy page 2023-03-08 20:28:01 +00:00
Eric Allam 28cad34c5f Deployment logs 2023-03-08 18:19:07 +00:00
James Ritchie 86d345bffc Improved the layout for the list of github repos 2023-03-08 15:07:15 +00:00
James Ritchie 9cdab61eaf Showing a conditional state if you have no repos connected yet 2023-03-08 14:00:37 +00:00
James Ritchie 48b35c47b4 Improvements to the Repository list 2023-03-08 13:00:57 +00:00
Eric Allam 349d7f38cc Added deploy and project list (live WIP) 2023-03-07 22:13:37 +00:00
Eric Allam 7cbc279407 Manual deploy and added a deployment version number 2023-03-07 16:24:32 +00:00
Eric Allam 301d500c3d Fixing the re-exporting of routes 2023-03-07 15:37:55 +00:00
Eric Allam 4150ba8786 If a repo is already in a project, then just link to it from the select repo page 2023-03-07 15:19:43 +00:00
Eric Allam f39bc44eec Projects
- Deploy a new VM when a push event comes through
- Live updating project overview page
2023-03-07 15:12:42 +00:00
Eric Allam 4fabd481e6 The repos that are included in a workflow are the most relevant repos 2023-03-07 15:12:42 +00:00
D-K-P a374338ac5 Added repo rename instructions to gh issues to slack / gh stars to slack 2023-03-07 10:05:33 +00:00
D-K-P a7337478d0 Added Resend queue jump instructions to the readme 2023-03-07 10:03:58 +00:00
James Ritchie a7b37e9f7b impersonate banner style fix 2023-03-03 15:14:35 +00:00
James Ritchie 48e0cff123 Lots of improvements to the app layout behaviour
Main improvements:

- You can have different footer if logged in or out
- Footers aren’t fixed to the bottom of the page if logged out
2023-03-03 15:14:35 +00:00
James Ritchie 8a4cc312a1 Templates and overview pages now viewable on logged in mobile 2023-03-03 15:14:35 +00:00
Eric Allam f008f803b8 Updating github app configuration works, and paginating all repos for an installation as well, and displaying a list of repos to deploy 2023-03-03 14:35:43 +00:00
Eric Allam b43224c353 Upgrade to remix v1.11.0 for streaming and defer support 2023-03-03 14:35:43 +00:00
Eric Allam 8bb9798ff4 Fire off an app installation callback 2023-03-03 14:35:43 +00:00
James Ritchie 86ae6f927a Fixed safari bug where h-full was shunting content off the screen 2023-03-03 11:53:35 +00:00
Eric Allam c0693ac508 Cleanup old github app stuff and octokit and prepare for installing new github app for cloud 2023-03-03 11:13:07 +00:00
Eric Allam ab512157a5 Added ability to patch all template repos and push the changes 2023-03-03 08:34:15 +00:00
Eric Allam 86475d4e3c Changeset: version (@trigger.dev/sdk v0.2.21) 2023-03-02 22:31:44 +00:00
Eric Allam c5084209eb Fix for metadata capture when using npm/yarn 2023-03-02 22:30:43 +00:00
Eric Allam eada69040c Bump notion 2023-03-02 22:30:43 +00:00
Eric Allam 6deabb21ba Bump notion 2023-03-02 22:30:43 +00:00
James Ritchie 2bb324752e Removed the Product Hunt Banner but kept it commented out for later 2023-03-02 18:05:59 +00:00
212 changed files with 9156 additions and 2323 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Fixed an error message
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Added kv storage to persist data in between runs and between workflows
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Fix ESM error by dynamically importing ESM packages (chalk, terminal-link, etc.)
+33
View File
@@ -0,0 +1,33 @@
{
"mode": "pre",
"tag": "next",
"initialVersions": {
"integrations": "1.0.0",
"webapp": "1.0.0",
"wss": "1.0.0",
"@trigger.dev/airtable": "0.1.27",
"@trigger.dev/notion": "0.1.26",
"@trigger.dev/sendgrid": "0.1.27",
"@trigger.dev/github": "0.1.27",
"@trigger.dev/resend": "0.1.27",
"@trigger.dev/shopify": "0.1.27",
"@trigger.dev/slack": "0.1.27",
"@trigger.dev/whatsapp": "0.1.25",
"@trigger.dev/common-schemas": "0.1.1",
"create-trigger": "0.2.0",
"emails": "1.0.0",
"integration-catalog": "0.1.16",
"@trigger.dev/integration-sdk": "0.1.17",
"internal-bridge": "0.0.1",
"internal-cli": "0.0.1",
"internal-platform": "0.0.3",
"internal-pulsar": "0.0.1",
"@trigger.dev/sdk": "0.2.21"
},
"changesets": [
"eleven-gorillas-obey",
"little-lizards-joke",
"moody-ligers-arrive",
"shaggy-hounds-appear"
]
}
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
SDK now passes through the project ID from the env var
+2 -1
View File
@@ -45,4 +45,5 @@ yarn-error.log*
.output
apps/**/public/build
.tests-container-id.txt
.sentryclirc
.sentryclirc
.buildt
+3
View File
@@ -52,6 +52,9 @@ windows:
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: ./scripts/proxy-pizzly.sh <your-pizzly-dev>
- cwd: /Users/eric/code/triggerdotdev/trigger.dev
commands:
- exec: ./scripts/proxy-wss.sh <your-wss-dev>
- title: wss and examples
layout:
split_direction: horizontal
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"build": "tsup",
"start": "node dist/index.js",
"dev": "concurrently \"tsup --watch\" \"nodemon -r dotenv/config -q dist/index.js\"",
"dev": "node -r dotenv/config dist/index.js",
"test": "vitest",
"typecheck": "tsc -b",
"lint": "eslint --no-error-on-unmatched-pattern src/**",
+192 -64
View File
@@ -1,64 +1,192 @@
<svg width="751" height="131" viewBox="0 0 751 131" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M195.022 16.2694H135.445H137.799V32.5133H157.858V102.411H174.84V32.5133H195.022V16.2694Z" fill="url(#paint0_linear_228_1439)"/>
<path d="M211.265 51.4645V40.8814H195.391V102.411H211.265V73C211.265 60.0787 221.726 56.3868 229.971 57.3713V39.6508C222.218 39.6508 214.465 43.0964 211.265 51.4645Z" fill="url(#paint1_linear_228_1439)"/>
<path d="M246.954 33.4978C252.368 33.4978 256.799 29.0676 256.799 23.7761C256.799 18.4845 252.368 13.9313 246.954 13.9313C241.662 13.9313 237.232 18.4845 237.232 23.7761C237.232 29.0676 241.662 33.4978 246.954 33.4978ZM239.078 102.411H254.953V40.8814H239.078V102.411Z" fill="url(#paint2_linear_228_1439)"/>
<path d="M315.253 40.8814V48.5111C310.946 42.7273 304.301 39.1586 295.563 39.1586C278.089 39.1586 264.922 53.4335 264.922 70.6618C264.922 88.0133 278.089 102.165 295.563 102.165C304.301 102.165 310.946 98.5964 315.253 92.8126V99.4579C315.253 109.18 309.1 114.594 299.132 114.594C289.657 114.594 285.596 110.779 283.011 105.98L269.475 113.733C274.889 123.701 285.473 128.746 298.64 128.746C314.884 128.746 330.759 119.64 330.759 99.4579V40.8814H315.253ZM298.025 87.5211C288.057 87.5211 280.796 80.5066 280.796 70.6618C280.796 60.9401 288.057 53.9257 298.025 53.9257C307.992 53.9257 315.253 60.9401 315.253 70.6618C315.253 80.5066 307.992 87.5211 298.025 87.5211Z" fill="url(#paint3_linear_228_1439)"/>
<path d="M390.936 40.8814V48.5111C386.629 42.7273 379.983 39.1586 371.246 39.1586C353.772 39.1586 340.604 53.4335 340.604 70.6618C340.604 88.0133 353.772 102.165 371.246 102.165C379.983 102.165 386.629 98.5964 390.936 92.8126V99.4579C390.936 109.18 384.783 114.594 374.815 114.594C365.339 114.594 361.278 110.779 358.694 105.98L345.157 113.733C350.572 123.701 361.155 128.746 374.323 128.746C390.566 128.746 406.441 119.64 406.441 99.4579V40.8814H390.936ZM373.707 87.5211C363.74 87.5211 356.479 80.5066 356.479 70.6618C356.479 60.9401 363.74 53.9257 373.707 53.9257C383.675 53.9257 390.936 60.9401 390.936 70.6618C390.936 80.5066 383.675 87.5211 373.707 87.5211Z" fill="url(#paint4_linear_228_1439)"/>
<path d="M432.9 78.1685H479.294C479.663 76.0765 479.909 73.9845 479.909 71.6464C479.909 53.5566 466.988 39.1586 448.775 39.1586C429.454 39.1586 416.287 53.3105 416.287 71.6464C416.287 89.9822 429.331 104.134 450.005 104.134C461.819 104.134 471.049 99.3348 476.832 90.9667L464.034 83.5832C461.327 87.1519 456.404 89.7361 450.251 89.7361C441.883 89.7361 435.115 86.2904 432.9 78.1685ZM432.654 65.8625C434.5 57.9867 440.284 53.4335 448.775 53.4335C455.42 53.4335 462.065 57.0022 464.034 65.8625H432.654Z" fill="url(#paint5_linear_228_1439)"/>
<path d="M505.199 51.4645V40.8814H489.324V102.411H505.199V73C505.199 60.0787 515.659 56.3868 523.904 57.3713V39.6508C516.151 39.6508 508.398 43.0964 505.199 51.4645Z" fill="url(#paint6_linear_228_1439)"/>
<path d="M529.934 104.011C535.717 104.011 540.394 99.3347 540.394 93.5509C540.394 87.7671 535.717 83.0909 529.934 83.0909C524.15 83.0909 519.474 87.7671 519.474 93.5509C519.474 99.3347 524.15 104.011 529.934 104.011Z" fill="url(#paint7_linear_228_1439)"/>
<path d="M596.632 16.2694V48.1419C592.202 42.4811 585.68 39.1586 576.696 39.1586C560.206 39.1586 546.67 53.3104 546.67 71.6463C546.67 89.9822 560.206 104.134 576.696 104.134C585.68 104.134 592.202 100.811 596.632 95.1507V102.411H612.507V16.2694L596.632 16.2694ZM579.65 88.9977C569.805 88.9977 562.544 81.9833 562.544 71.6463C562.544 61.3093 569.805 54.2948 579.65 54.2948C589.371 54.2948 596.632 61.3093 596.632 71.6463C596.632 81.9833 589.371 88.9977 579.65 88.9977Z" fill="url(#paint8_linear_228_1439)"/>
<path d="M637.98 78.1685H684.374C684.743 76.0765 684.989 73.9845 684.989 71.6464C684.989 53.5566 672.068 39.1586 653.855 39.1586C634.534 39.1586 621.367 53.3105 621.367 71.6464C621.367 89.9823 634.411 104.134 655.085 104.134C666.899 104.134 676.129 99.3348 681.912 90.9667L669.114 83.5832C666.407 87.1519 661.484 89.7362 655.331 89.7362C646.963 89.7362 640.195 86.2905 637.98 78.1685ZM637.734 65.8625C639.58 57.9867 645.364 53.4335 653.855 53.4335C660.5 53.4335 667.145 57.0022 669.114 65.8625H637.734Z" fill="url(#paint9_linear_228_1439)"/>
<path d="M732.859 40.8814L717.846 83.9523L702.956 40.8814H685.481L708.863 102.411H726.952L750.334 40.8814H732.859Z" fill="url(#paint10_linear_228_1439)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.664 42.3995L59.4114 1.26865L118.264 103.206H0.558823L24.3062 62.0734L41.1046 71.7723L34.157 83.8065H84.6658L59.4114 40.0656L52.4638 52.0998L35.664 42.3995Z" fill="url(#paint11_linear_228_1439)"/>
<defs>
<linearGradient id="paint0_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint1_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint2_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint3_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint4_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint5_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint6_linear_228_1439" x1="329.675" y1="150.096" x2="329.675" y2="13.9313" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
<linearGradient id="paint7_linear_228_1439" x1="634.904" y1="139.733" x2="651.44" y2="25.9754" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="paint8_linear_228_1439" x1="634.904" y1="139.733" x2="651.44" y2="25.9754" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="paint9_linear_228_1439" x1="634.904" y1="139.733" x2="651.44" y2="25.9754" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="paint10_linear_228_1439" x1="634.904" y1="139.733" x2="651.44" y2="25.9754" gradientUnits="userSpaceOnUse">
<stop stop-color="#2563EB"/>
<stop offset="1" stop-color="#A855F7"/>
</linearGradient>
<linearGradient id="paint11_linear_228_1439" x1="95.8594" y1="103.206" x2="94.7605" y2="31.2414" gradientUnits="userSpaceOnUse">
<stop stop-color="#41FF54"/>
<stop offset="1" stop-color="#E7FF52"/>
</linearGradient>
</defs>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="751"
height="131"
fill="none"
viewBox="0 0 751 131"
>
<path
fill="url(#paint0_linear_228_1439)"
d="M195.022 16.27h-59.577 2.354v16.243h20.059v69.898h16.982V32.513h20.182V16.27z"
></path>
<path
fill="url(#paint1_linear_228_1439)"
d="M211.265 51.465V40.88h-15.874v61.53h15.874V73c0-12.921 10.461-16.613 18.706-15.629v-17.72c-7.753 0-15.506 3.445-18.706 11.813z"
></path>
<path
fill="url(#paint2_linear_228_1439)"
d="M246.954 33.498c5.414 0 9.845-4.43 9.845-9.722 0-5.291-4.431-9.845-9.845-9.845-5.292 0-9.722 4.554-9.722 9.845 0 5.292 4.43 9.722 9.722 9.722zm-7.876 68.913h15.875v-61.53h-15.875v61.53z"
></path>
<path
fill="url(#paint3_linear_228_1439)"
d="M315.253 40.881v7.63c-4.307-5.784-10.952-9.352-19.69-9.352-17.474 0-30.641 14.275-30.641 31.503 0 17.351 13.167 31.503 30.641 31.503 8.738 0 15.383-3.569 19.69-9.352v6.645c0 9.722-6.153 15.136-16.121 15.136-9.475 0-13.536-3.815-16.121-8.614l-13.536 7.753c5.414 9.968 15.998 15.013 29.165 15.013 16.244 0 32.119-9.106 32.119-29.288V40.88h-15.506zm-17.228 46.64c-9.968 0-17.229-7.014-17.229-16.86 0-9.72 7.261-16.735 17.229-16.735 9.967 0 17.228 7.014 17.228 16.736 0 9.845-7.261 16.86-17.228 16.86z"
></path>
<path
fill="url(#paint4_linear_228_1439)"
d="M390.936 40.881v7.63c-4.307-5.784-10.953-9.352-19.69-9.352-17.474 0-30.642 14.275-30.642 31.503 0 17.351 13.168 31.503 30.642 31.503 8.737 0 15.383-3.569 19.69-9.352v6.645c0 9.722-6.153 15.136-16.121 15.136-9.476 0-13.537-3.815-16.121-8.614l-13.537 7.753c5.415 9.968 15.998 15.013 29.166 15.013 16.243 0 32.118-9.106 32.118-29.288V40.88h-15.505zm-17.229 46.64c-9.967 0-17.228-7.014-17.228-16.86 0-9.72 7.261-16.735 17.228-16.735 9.968 0 17.229 7.014 17.229 16.736 0 9.845-7.261 16.86-17.229 16.86z"
></path>
<path
fill="url(#paint5_linear_228_1439)"
d="M432.9 78.168h46.394c.369-2.092.615-4.184.615-6.522 0-18.09-12.921-32.487-31.134-32.487-19.321 0-32.488 14.151-32.488 32.487 0 18.336 13.044 32.488 33.718 32.488 11.814 0 21.044-4.8 26.827-13.167l-12.798-7.384c-2.707 3.569-7.63 6.153-13.783 6.153-8.368 0-15.136-3.446-17.351-11.568zm-.246-12.306c1.846-7.875 7.63-12.428 16.121-12.428 6.645 0 13.29 3.568 15.259 12.428h-31.38z"
></path>
<path
fill="url(#paint6_linear_228_1439)"
d="M505.199 51.465V40.88h-15.875v61.53h15.875V73c0-12.921 10.46-16.613 18.705-15.629v-17.72c-7.753 0-15.506 3.445-18.705 11.813z"
></path>
<path
fill="url(#paint7_linear_228_1439)"
d="M529.934 104.011c5.783 0 10.46-4.676 10.46-10.46 0-5.784-4.677-10.46-10.46-10.46a10.451 10.451 0 00-10.46 10.46c0 5.784 4.676 10.46 10.46 10.46z"
></path>
<path
fill="url(#paint8_linear_228_1439)"
d="M596.632 16.27v31.872c-4.43-5.66-10.952-8.983-19.936-8.983-16.49 0-30.026 14.151-30.026 32.487 0 18.336 13.536 32.488 30.026 32.488 8.984 0 15.506-3.323 19.936-8.983v7.26h15.875V16.269h-15.875zM579.65 88.997c-9.845 0-17.106-7.015-17.106-17.352s7.261-17.351 17.106-17.351c9.721 0 16.982 7.014 16.982 17.351s-7.261 17.352-16.982 17.352z"
></path>
<path
fill="url(#paint9_linear_228_1439)"
d="M637.98 78.168h46.394c.369-2.092.615-4.184.615-6.522 0-18.09-12.921-32.487-31.134-32.487-19.321 0-32.488 14.151-32.488 32.487 0 18.336 13.044 32.488 33.718 32.488 11.814 0 21.044-4.8 26.827-13.167l-12.798-7.384c-2.707 3.569-7.63 6.153-13.783 6.153-8.368 0-15.136-3.446-17.351-11.568zm-.246-12.306c1.846-7.875 7.63-12.428 16.121-12.428 6.645 0 13.29 3.568 15.259 12.428h-31.38z"
></path>
<path
fill="url(#paint10_linear_228_1439)"
d="M732.859 40.881l-15.013 43.071-14.89-43.07h-17.475l23.382 61.529h18.089l23.382-61.53h-17.475z"
></path>
<path
fill="url(#paint11_linear_228_1439)"
fillRule="evenodd"
d="M35.664 42.4L59.411 1.269l58.853 101.937H.559l23.747-41.133 16.799 9.7-6.948 12.034h50.509L59.41 40.066 52.464 52.1l-16.8-9.7z"
clipRule="evenodd"
></path>
<defs>
<linearGradient
id="paint0_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint1_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint2_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint3_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint4_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint5_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint6_linear_228_1439"
x1="329.675"
x2="329.675"
y1="150.096"
y2="13.931"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
<linearGradient
id="paint7_linear_228_1439"
x1="634.904"
x2="651.44"
y1="139.733"
y2="25.975"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB"></stop>
<stop offset="1" stopColor="#A855F7"></stop>
</linearGradient>
<linearGradient
id="paint8_linear_228_1439"
x1="634.904"
x2="651.44"
y1="139.733"
y2="25.975"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB"></stop>
<stop offset="1" stopColor="#A855F7"></stop>
</linearGradient>
<linearGradient
id="paint9_linear_228_1439"
x1="634.904"
x2="651.44"
y1="139.733"
y2="25.975"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB"></stop>
<stop offset="1" stopColor="#A855F7"></stop>
</linearGradient>
<linearGradient
id="paint10_linear_228_1439"
x1="634.904"
x2="651.44"
y1="139.733"
y2="25.975"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#2563EB"></stop>
<stop offset="1" stopColor="#A855F7"></stop>
</linearGradient>
<linearGradient
id="paint11_linear_228_1439"
x1="95.859"
x2="94.76"
y1="103.206"
y2="31.241"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54"></stop>
<stop offset="1" stopColor="#E7FF52"></stop>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -63,7 +63,7 @@ export function CopyTextButton({
const panelVariantStyle = {
primary:
"truncate text-indigo-300 bg-indigo-700/50 pl-3.5 pr-2 py-3 rounded border border-indigo-600 flex items-center justify-between gap-2 hover:cursor-pointer hover:bg-indigo-600/50 hover:border-indigo-600 transition",
"truncate text-indigo-100 bg-indigo-700/50 pl-3.5 pr-2 py-3 rounded border border-indigo-600 flex items-center justify-between gap-2 hover:cursor-pointer hover:bg-indigo-600/50 hover:border-indigo-600 transition",
slate:
"flex w-full items-center justify-between gap-2 truncate rounded bg-slate-850 py-2 pl-2.5 pr-1 transition hover:cursor-pointer hover:bg-slate-800 hover:text-slate-300",
text: "flex w-full items-center justify-between gap-2 truncate rounded bg-transparent py-2 pl-2.5 pr-1 transition hover:cursor-pointer hover:border-slate-700/50 hover:bg-slate-700/50",
@@ -8,7 +8,7 @@ export function ImpersonationBanner({
impersonationId: string;
}) {
return (
<div className="grid grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 px-4 text-center">
<div className="grid h-8 grid-cols-3 items-center bg-gradient-to-r from-amber-500 via-amber-400 to-amber-500 px-4 text-center">
<span></span>
<Body size="small" className="text-slate-800">
You are impersonating{" "}
@@ -22,7 +22,7 @@ export function ImpersonationBanner({
>
<TertiaryButton
type="submit"
className="text-slate-700 hover:!text-slate-900"
className="!text-slate-700 transition hover:!text-slate-900"
>
Stop impersonating
</TertiaryButton>
+55
View File
@@ -0,0 +1,55 @@
import { useLocales } from "./LocaleProvider";
type IntlDateProps = {
date: Date;
timeZone?: string;
};
export const IntlDate = ({ date, timeZone }: IntlDateProps) => {
const locales = useLocales();
const isoString = date.toISOString();
const formattedDate = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "short",
day: "numeric",
timeZone,
}).format(date);
const formattedTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
timeZone,
}).format(date);
return (
<time dateTime={isoString}>
{formattedDate} at {formattedTime}
</time>
);
};
export const LogDate = ({ date, timeZone }: IntlDateProps) => {
const locales = useLocales();
const isoString = date.toISOString();
const formattedDate = new Intl.DateTimeFormat(locales, {
month: "short",
day: "2-digit",
timeZone,
}).format(date);
const formattedTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone,
}).format(date);
const milliseconds = `00${date.getMilliseconds()}`.slice(-3);
return (
<time dateTime={isoString}>
{formattedDate} {formattedTime}.{milliseconds}
</time>
);
};
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { useContext } from "react";
import { createContext } from "react";
type LocaleContext = {
locales: string[];
};
type LocaleContextProviderProps = {
locales: string[];
children: ReactNode;
};
const Context = createContext<LocaleContext | null>(null);
export const LocaleContextProvider = ({
locales,
children,
}: LocaleContextProviderProps) => {
const value = { locales };
return <Context.Provider value={value}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error("Please wrap your application in a LocaleContextProvider.");
};
export const useLocales = () => {
const { locales } = useContext(Context) ?? throwIfNoProvider();
return locales;
};
+32
View File
@@ -0,0 +1,32 @@
export function LogoIcon({ className }: { className?: string }) {
return (
<svg
width="321"
height="282"
viewBox="0 0 321 282"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M96.1017 113.4L160.679 4.57764e-05L320.718 281.045H0.638916L65.2159 167.642L110.896 194.382L92.0035 227.561H229.354L160.679 106.965L141.786 140.144L96.1017 113.4Z"
fill="url(#paint0_linear_465_1663)"
/>
<defs>
<linearGradient
id="paint0_linear_465_1663"
x1="320.718"
y1="140.687"
x2="0.638918"
y2="140.687"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#41FF54" />
<stop offset="1" stopColor="#E7FF52" />
</linearGradient>
</defs>
</svg>
);
}
@@ -5,7 +5,7 @@ import { ArrowRightIcon } from "@heroicons/react/20/solid";
export function ProductHuntBanner() {
return (
<div className="flex items-center justify-center gap-2 bg-[#ff6154]">
<div className="flex h-8 items-center justify-center gap-2 bg-[#ff6154]">
<Body size="small" className="text-white">
We're live on{" "}
</Body>
@@ -51,7 +51,7 @@ export function WorkflowConnections({
</div>
</li>
))}
<li className="font-sm p-4 pl-5 text-slate-500">
<li className="p-4 pl-5 text-sm text-slate-500">
You will be able to authenticate APIs on demand when your workflow
runs. You can also{" "}
<Link
+22 -20
View File
@@ -1,24 +1,14 @@
import classNames from "classnames";
import { ImpersonationBanner } from "../ImpersonationBanner";
export function AppLayout({
children,
impersonationId,
}: {
children: React.ReactNode;
impersonationId?: string;
}) {
if (impersonationId) {
return (
<div className="grid h-full w-full grid-rows-[2rem_2rem_3rem_auto_2rem]">
<ImpersonationBanner impersonationId={impersonationId} />
{children}
</div>
);
}
export function AppLayoutThreeCol({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-full w-full grid-rows-[2rem_3rem_auto_2rem]">
<div className="grid h-full w-full grid-cols-[3.5rem_auto]">{children}</div>
);
}
export function AppLayoutTwoCol({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-full w-full grid-cols-[16rem_auto] overflow-hidden">
{children}
</div>
);
@@ -26,7 +16,7 @@ export function AppLayout({
export function PublicAppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grid h-full w-full grid-rows-[2rem_auto_auto_2rem]">
<div className="grid h-full w-full grid-rows-[3.6rem_auto_auto] overflow-y-auto">
{children}
</div>
);
@@ -40,6 +30,18 @@ export function AppBody({
className?: string;
}) {
return (
<div className={classNames("overflow-y-auto", className)}>{children}</div>
<div className={classNames("h-full overflow-y-auto", className)}>
{children}
</div>
);
}
export function PublicAppBody({
children,
className = "bg-slate-850",
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={classNames("", className)}>{children}</div>;
}
+7 -7
View File
@@ -1,11 +1,11 @@
export function Footer() {
const linkStyle =
"text-xs font-medium text-slate-400 whitespace-nowrap bg-transparent text-slate-500 transition hover:text-indigo-500";
const linkStyle =
"text-xs font-medium text-slate-400 whitespace-nowrap bg-transparent text-slate-500 transition hover:text-indigo-500";
export function Footer() {
return (
<div className="flex w-full items-center justify-between border-t border-slate-800 bg-slate-950 pl-2 pr-3 py-1 flex-row gap-0">
<p className="text-xs text-slate-500">
&copy; {new Date().getFullYear()} Trigger.dev{" "}
<div className="flex w-full flex-col items-center justify-between border-t border-slate-800 bg-slate-950 py-4 pl-2 pr-3 sm:flex-row sm:py-4">
<div className="flex gap-2 pb-4 text-xs text-slate-500 sm:pb-0">
<p>&copy; {new Date().getFullYear()} Trigger.dev </p>
<span className="text-slate-600">|</span>{" "}
<a
className="transition hover:text-indigo-500"
@@ -20,7 +20,7 @@ export function Footer() {
>
Privacy
</a>
</p>
</div>
<div className="flex gap-3">
<a
+23 -18
View File
@@ -1,36 +1,41 @@
import { DocumentTextIcon } from "@heroicons/react/24/outline";
import {
ArrowTopRightOnSquareIcon,
ChatBubbleLeftRightIcon,
} from "@heroicons/react/24/outline";
import { Link } from "@remix-run/react";
import { ProjectMenu } from "~/features/ee/projects/components/ProjectMenu";
import { useOptionalUser } from "~/hooks/useUser";
import { Logo } from "../Logo";
import { EnvironmentMenu } from "../../routes/resources/environment";
import { OrganizationMenu } from "../navigation/OrganizationMenu";
import { WorkflowMenu } from "../navigation/WorkflowMenu";
import { TertiaryA } from "../primitives/Buttons";
import { SecondaryA, SecondaryButton } from "../primitives/Buttons";
import { UserProfileMenu } from "../UserProfileMenu";
type HeaderProps = {
children?: React.ReactNode;
context: "workflows" | "projects";
};
export function Header({ children }: HeaderProps) {
export function Header({ children, context }: HeaderProps) {
const user = useOptionalUser();
return (
<div className="flex w-full gap-2 items-center border-b border-slate-800 bg-slate-950 py-1 pl-4 pr-3">
<Link to="/" aria-label="Trigger" className="flex shrink-0 mr-2 w-28">
<Logo className="" />
</Link>
<OrganizationMenu />
<WorkflowMenu />
<EnvironmentMenu />
<div className="sticky top-0 z-50 flex h-[3.6rem] w-full items-center gap-2 border-b border-slate-800 bg-slate-950 py-1 pl-2 pr-3">
<div className="hidden items-center sm:flex">
<OrganizationMenu />
{context === "workflows" ? <WorkflowMenu /> : <ProjectMenu />}
<EnvironmentMenu />
</div>
<div className="flex flex-1 justify-center">{children}</div>
<div className="flex items-center gap-4">
<TertiaryA href="https://docs.trigger.dev" target="_blank">
<DocumentTextIcon className="h-5 w-5" />
Docs
</TertiaryA>
<div className="flex items-center gap-2">
<SecondaryA href="https://docs.trigger.dev" target="_blank">
<ArrowTopRightOnSquareIcon className="-ml-1 h-4 w-4" />
Documentation
</SecondaryA>
<SecondaryButton data-attr="posthog-feedback-button">
<ChatBubbleLeftRightIcon className="-ml-1 h-4 w-4" />
Send us feedback
</SecondaryButton>
{user ? (
<UserProfileMenu user={user} />
) : (
@@ -40,7 +40,7 @@ function MobileNavigation() {
className="absolute inset-x-6 top-full mt-4 flex origin-top flex-col gap-4 rounded-2xl bg-slate-800 p-6 text-lg tracking-tight text-slate-900 shadow-xl ring-1 ring-slate-700"
>
<PrimaryLink
className="whitespace-nowrap text-base"
className="w-full !max-w-none whitespace-nowrap text-base"
to="https://app.trigger.dev"
>
Sign up
@@ -80,7 +80,7 @@ function MobileNavigation() {
export function MarketingHeader() {
return (
<>
<header className="z-50 w-full bg-slate-900">
<header className="sticky top-0 z-50 w-full bg-slate-900">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-4 py-6 sm:px-10 lg:px-16 ">
<div className="flex items-center gap-x-6 md:gap-x-[56px]">
<a
@@ -29,6 +29,28 @@ export function PanelInfo({
);
}
export function PanelWarning({
children,
className,
message,
}: {
children?: React.ReactNode;
className?: string;
message?: string;
}) {
return (
<IconPanel
className={className}
message={message}
icon={
<InformationCircleIcon className="h-6 w-6 min-w-[24px] text-yellow-500" />
}
>
{children}
</IconPanel>
);
}
export function PanelLoading({
children,
className,
@@ -62,7 +84,7 @@ export function IconPanel({
}) {
return (
<div
className={`flex w-full justify-between gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-4 shadow-md backdrop-blur-sm ${className}`}
className={`flex w-full gap-4 rounded-md border border-slate-600 bg-slate-400/10 py-3 pl-3 pr-4 shadow-md backdrop-blur-sm ${className}`}
>
<div className="flex items-center justify-start gap-2.5">
{icon}
@@ -30,7 +30,6 @@ export function OrganizationMenu() {
return (
<>
<BreadcrumbDivider />
<div className="w-full max-w-max">
<Popover className="relative">
{({ open }) => (
+275 -143
View File
@@ -1,26 +1,23 @@
import {
EyeIcon,
EyeSlashIcon,
InformationCircleIcon,
} from "@heroicons/react/20/solid";
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/20/solid";
import {
ArrowsRightLeftIcon,
ArrowTopRightOnSquareIcon,
BeakerIcon,
ChevronLeftIcon,
BuildingOffice2Icon,
CloudArrowUpIcon,
CloudIcon,
Cog6ToothIcon,
EnvelopeIcon,
ForwardIcon,
PhoneArrowUpRightIcon,
PlusCircleIcon,
HomeIcon,
QueueListIcon,
Squares2X2Icon,
SquaresPlusIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import { Link, NavLink } from "@remix-run/react";
import { Link, NavLink, useLocation } from "@remix-run/react";
import { useState } from "react";
import invariant from "tiny-invariant";
import { CurrentProject } from "~/features/ee/projects/routes/projects/$projectP";
import { useCurrentEnvironment } from "~/hooks/useEnvironments";
import { useIsOrgChildPage } from "~/hooks/useIsOrgChildPage";
import {
useCurrentOrganization,
useOrganizations,
@@ -29,19 +26,25 @@ import { useCurrentWorkflow } from "~/hooks/useWorkflows";
import { EnvironmentIcon } from "~/routes/resources/environment";
import { titleCase } from "~/utils";
import { CopyTextPanel } from "../CopyTextButton";
import { TertiaryA, TertiaryButton } from "../primitives/Buttons";
import { LogoIcon } from "../LogoIcon";
import { TertiaryButton } from "../primitives/Buttons";
import { MenuTitleToolTip } from "../primitives/MenuTitleToolTip";
import { Body } from "../primitives/text/Body";
import { Header1 } from "../primitives/text/Headers";
import { Tooltip } from "../primitives/Tooltip";
export function SideMenuContainer({ children }: { children: React.ReactNode }) {
return <div className="grid h-full grid-cols-[300px_2fr]">{children}</div>;
return (
<div className="grid h-full grid-cols-[250px_2fr] overflow-hidden">
{children}
</div>
);
}
type SideMenuItem = {
name: string;
icon: React.ReactNode;
to: string;
end?: boolean;
};
const iconStyle = "h-6 w-6";
@@ -59,35 +62,131 @@ export function OrganizationsSideMenu() {
name: "Workflows",
icon: <ArrowsRightLeftIcon className={iconStyle} />,
to: `/orgs/${currentOrganization.slug}`,
end: true,
},
];
if (currentOrganization.workflows.length > 0) {
items = [
...items,
{
name: "Repositories",
icon: <CloudIcon className={iconStyle} />,
to: `/orgs/${currentOrganization.slug}/projects`,
end: false,
},
{
name: "API Integrations",
icon: <SquaresPlusIcon className={iconStyle} />,
to: `/orgs/${currentOrganization.slug}/integrations`,
},
{
name: "Team",
icon: <UsersIcon className={iconStyle} />,
to: `/orgs/${currentOrganization.slug}/members`,
},
{
name: "New Workflow",
icon: <PlusCircleIcon className={iconStyle} />,
to: `/orgs/${currentOrganization.slug}/workflows/new`,
end: false,
},
];
}
return (
<SideMenu title={currentOrganization.title} items={items} backPath="/" />
<SideMenu
subtitle="Organization"
title={currentOrganization.title}
items={items}
backPath="/"
/>
);
}
export function CurrentOrganizationSideMenu() {
const organizations = useOrganizations();
const currentOrganization = useCurrentOrganization();
if (organizations === undefined || currentOrganization === undefined) {
return null;
}
return (
<ul className="flex h-full flex-col items-center justify-start space-y-2 border-r border-slate-800 bg-slate-950">
<NavLink
to="/"
className="flex h-[3.6rem] w-full items-center justify-center border-b border-slate-800"
>
<MenuTitleToolTip text="All Organizations">
<li className="rounded p-2 transition hover:bg-slate-800">
<LogoIcon className="h-6 w-6" />
</li>
</MenuTitleToolTip>
</NavLink>
<MenuTitleToolTip text={currentOrganization.title}>
<NavLink
to={`/orgs/${currentOrganization.slug}`}
className={(isActive) =>
isActive ? activeCollapsedStyle : defaultCollapsedStyle
}
>
<li>
<BuildingOffice2Icon className="h-6 w-6 text-slate-300" />
</li>
</NavLink>
</MenuTitleToolTip>
</ul>
);
}
export function OrganizationSideMenuCollapsed() {
const organizations = useOrganizations();
const currentOrganization = useCurrentOrganization();
if (organizations === undefined || currentOrganization === undefined) {
return null;
}
return (
<ul className="flex h-full flex-col items-center justify-start space-y-2 border-r border-slate-800 bg-slate-950">
<NavLink
to="/"
className="flex h-[3.6rem] w-full items-center justify-center border-b border-slate-800"
>
<li className="rounded p-2 transition hover:bg-slate-800">
<LogoIcon className="h-6 w-6" />
</li>
</NavLink>
<MenuTitleToolTip text="Workflows">
<WorkflowsNavLink slug={currentOrganization.slug}>
<li>
<ArrowsRightLeftIcon className="h-6 w-6 text-slate-300" />
</li>
</WorkflowsNavLink>
</MenuTitleToolTip>
<MenuTitleToolTip text="Repositories">
<NavLink
to={`/orgs/${currentOrganization.slug}/projects`}
className={({ isActive }) =>
isActive ? activeCollapsedStyle : defaultCollapsedStyle
}
>
<li>
<CloudIcon className="h-6 w-6 text-slate-300" />
</li>
</NavLink>
</MenuTitleToolTip>
<MenuTitleToolTip text="API Integrations">
<NavLink
to={`/orgs/${currentOrganization.slug}/integrations`}
className={({ isActive }) =>
isActive ? activeCollapsedStyle : defaultCollapsedStyle
}
>
<li>
<SquaresPlusIcon className="h-6 w-6 text-slate-300" />
</li>
</NavLink>
</MenuTitleToolTip>
</ul>
);
}
const defaultCollapsedStyle = "rounded p-2 transition hover:bg-slate-800";
const activeCollapsedStyle =
"rounded p-2 transition bg-slate-800 hover:bg-slate-600/50";
export function WorkflowsSideMenu() {
const currentWorkflow = useCurrentWorkflow();
const organization = useCurrentOrganization();
@@ -108,8 +207,9 @@ export function WorkflowsSideMenu() {
let items: SideMenuItem[] = [
{
name: "Overview",
icon: <ArrowsRightLeftIcon className={iconStyle} />,
icon: <HomeIcon className={iconStyle} />,
to: ``,
end: true,
},
];
@@ -145,6 +245,7 @@ export function WorkflowsSideMenu() {
return (
<SideMenu
subtitle="Workflow"
title={currentWorkflow.title}
items={items}
backPath={`/orgs/${organization.slug}`}
@@ -152,80 +253,132 @@ export function WorkflowsSideMenu() {
);
}
export function ProjectSideMenu({
project,
backPath,
}: {
project: CurrentProject;
backPath: string;
}) {
if (!project) {
return null;
}
const items: SideMenuItem[] = [
{
name: "Overview",
icon: <HomeIcon className={iconStyle} />,
to: ``,
end: true,
},
{
name: "Deploys",
icon: <CloudArrowUpIcon className={iconStyle} />,
to: `deploys`,
},
{
name: "Logs",
icon: <QueueListIcon className={iconStyle} />,
to: `logs`,
},
{
name: "Settings",
icon: <Cog6ToothIcon className={iconStyle} />,
to: `settings`,
},
];
return (
<SideMenu
subtitle="Repository"
title={project.name}
items={items}
backPath={backPath}
/>
);
}
const defaultStyle =
"group flex items-center gap-2 px-3 py-3 text-base rounded transition text-slate-300 hover:bg-slate-850 hover:text-white";
const activeStyle =
"group flex items-center gap-2 px-3 py-3 text-base rounded transition bg-slate-800 text-white";
function SideMenu({
title,
items,
backPath,
title,
subtitle,
}: {
title: string;
items: SideMenuItem[];
backPath: string;
subtitle: string;
}) {
const organization = useCurrentOrganization();
invariant(organization, "Organization must be defined");
const isOrgChildPage = useIsOrgChildPage();
const [isShowingKeys, setIsShowingKeys] = useState(false);
return (
<div className="flex min-h-0 flex-1 flex-col border-r border-slate-800 bg-slate-950">
<div className="flex flex-1 flex-col overflow-y-auto pb-4">
<nav
className="mt-2 flex h-full flex-col justify-between space-y-1 px-2"
aria-label="Sidebar"
className="flex h-full flex-col justify-between space-y-1"
aria-label="Side menu"
>
<div>
<div className="group my-2 flex items-center divide-x divide-transparent rounded border border-transparent text-slate-400 transition hover:divide-slate-900 hover:border-slate-800 hover:bg-slate-950">
<Link
to={backPath}
className="rounded-l px-2 py-3 transition hover:bg-slate-800"
>
<ChevronLeftIcon className="h-5 w-5 text-slate-400" />
</Link>
<div className="flex flex-col">
{isOrgChildPage ? (
<div className="flex h-[3.6rem] items-center border-b border-slate-800 pl-5 pr-1">
<Header1
size="extra-small"
className="overflow-hidden text-ellipsis whitespace-nowrap text-slate-300"
>
{title}
</Header1>
</div>
) : (
<div className="flex h-[3.6rem] items-center border-b border-slate-800 pl-5 pr-1">
<Header1
size="extra-small"
className="overflow-hidden text-ellipsis whitespace-nowrap text-slate-300"
>
{title}
</Header1>
</div>
)}
<Header1
<div className="p-2">
<Body
size="small"
className="w-full overflow-hidden text-ellipsis whitespace-nowrap rounded-r py-2 pl-2 text-slate-400 transition hover:bg-slate-800"
className="py-3 pl-3 uppercase tracking-wider text-slate-400"
>
<Link to="">{title}</Link>
</Header1>
{subtitle}
</Body>
{items.map((item) => (
<NavLink
key={item.name}
to={item.to}
end={item.end}
className={({ isActive }) =>
isActive ? activeStyle : defaultStyle
}
>
{item.icon}
<span>{item.name}</span>
</NavLink>
))}
</div>
{items.map((item) => (
<NavLink
key={item.name}
to={item.to}
end
className={({ isActive }) =>
isActive ? activeStyle : defaultStyle
}
>
{item.icon}
<span>{item.name}</span>
</NavLink>
))}
</div>
<div className="flex flex-col gap-6">
<ul className="ml-3 mr-2 flex flex-col gap-2">
<li className="flex w-full items-center justify-between">
<TertiaryA
href="https://docs.trigger.dev/guides/environments"
target="_blank"
className="group flex items-center gap-1 transition"
<Body
size="extra-small"
className={`overflow-hidden text-slate-300 transition group-hover:text-slate-400 ${menuSmallTitleStyle}`}
>
<InformationCircleIcon className="h-4 w-4 text-slate-500 transition group-hover:text-slate-400" />
<Body
size="extra-small"
className={`overflow-hidden text-slate-300 transition group-hover:text-slate-400 ${menuSmallTitleStyle}`}
>
API keys
</Body>
</TertiaryA>
API keys
</Body>
{!isShowingKeys ? (
<TertiaryButton
onClick={() => setIsShowingKeys(true)}
@@ -244,84 +397,42 @@ function SideMenu({
</li>
{organization.environments.map((environment) => {
return (
<Tooltip
<li
key={environment.id}
text={
environment.slug === "live"
? "Use in live / production"
: "Use in dev / local"
}
className="flex w-full flex-col justify-between"
>
<li className="flex w-full flex-col justify-between">
<div className="relative flex items-center">
<EnvironmentIcon
slug={environment.slug}
className="absolute top-4 left-2"
/>
<CopyTextPanel
value={environment.apiKey}
text={
isShowingKeys
? environment.apiKey
: `${titleCase(environment.slug)}`
}
variant="slate"
className="pl-6 text-slate-300 hover:text-slate-300"
/>
</div>
</li>
</Tooltip>
<div className="relative flex items-center">
<EnvironmentIcon
slug={environment.slug}
className="absolute top-4 left-2"
/>
<CopyTextPanel
value={environment.apiKey}
text={
isShowingKeys
? environment.apiKey
: `${titleCase(environment.slug)}`
}
variant="slate"
className="pl-6 text-slate-300 hover:text-slate-300"
/>
</div>
</li>
);
})}
</ul>
<ul className="ml-3 flex flex-col gap-3">
<li>
<Body size="extra-small" className={menuSmallTitleStyle}>
Help and resources
</Body>
</li>
<li>
<Body size="extra-small" className="mt-1 text-slate-500">
Copy these keys into your workflow code. Use the Live key for
production and Development key for local. Learn more about API
keys{" "}
<a
href="https://docs.trigger.dev"
href="https://docs.trigger.dev/guides/environments"
target="_blank"
rel="noreferrer"
className={menuSmallLinkStyle}
className="underline underline-offset-2 transition hover:text-slate-200"
>
<ArrowTopRightOnSquareIcon className={menuSmallIconStyle} />
Documentation
here
</a>
</li>
<li>
<a
href="https://docs.trigger.dev/getting-started"
rel="noreferrer"
target="_blank"
className={menuSmallLinkStyle}
>
<ArrowTopRightOnSquareIcon className={menuSmallIconStyle} />
Quick start guide
</a>
</li>
<li>
<a
href="https://cal.com/team/triggerdotdev/call"
rel="noreferrer"
target="_blank"
className={menuSmallLinkStyle}
>
<PhoneArrowUpRightIcon className={menuSmallIconStyle} />
Schedule a call
</a>
</li>
<li>
<a
href="mailto:hello@trigger.dev"
className={menuSmallLinkStyle}
>
<EnvelopeIcon className={menuSmallIconStyle} />
Contact us
</a>
</li>
.
</Body>
</ul>
</div>
</nav>
@@ -330,7 +441,28 @@ function SideMenu({
);
}
function WorkflowsNavLink({
slug,
children,
}: {
slug: string;
children: React.ReactNode;
}) {
const location = useLocation();
const isActive =
location.pathname === `/orgs/${slug}` ||
location.pathname.startsWith(`/orgs/${slug}/workflows`);
return (
<Link
to={`/orgs/${slug}`}
prefetch="intent"
className={isActive ? activeCollapsedStyle : defaultCollapsedStyle}
>
{children}
</Link>
);
}
const menuSmallTitleStyle = "uppercase text-slate-500 tracking-wide";
const menuSmallLinkStyle =
"flex gap-1.5 text-slate-400 hover:text-white text-sm items-center transition";
const menuSmallIconStyle = "h-4 w-4";
@@ -7,11 +7,11 @@ const commonClasses =
"inline-flex items-center justify-center max-w-max rounded transition whitespace-nowrap";
const primaryClasses = classnames(
commonClasses,
"px-4 py-2 bg-indigo-700 text-white hover:bg-indigo-600 focus:ring-indigo-800 gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-400"
"px-4 py-2 bg-indigo-700 text-white hover:bg-indigo-600 focus-visible:ring-indigo-800 gap-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-slate-700 disabled:text-slate-400"
);
const secondaryClasses = classnames(
commonClasses,
"px-4 py-2 bg-transparent ring-1 ring-slate-700 ring-inset text-white hover:bg-white/5 hover:border-slate-700 focus:ring-slate-300 gap-2"
"px-4 py-2 bg-transparent ring-1 ring-slate-700 ring-inset text-white hover:bg-white/5 hover:border-slate-700 focus-visible:ring-slate-300 gap-2"
);
const tertiaryClasses = classnames(
commonClasses,
@@ -19,11 +19,11 @@ const tertiaryClasses = classnames(
);
const dangerClasses = classnames(
commonClasses,
"px-4 py-2 bg-rose-700 text-white hover:bg-rose-600 focus:ring-rose-800 gap-2"
"px-4 py-2 bg-rose-700 text-white hover:bg-rose-600 focus-visible:ring-rose-800 gap-2"
);
const toxicClasses = classnames(
commonClasses,
"hover:cursor-pointer px-3 py-1 transition bg-gradient-to-r from-acid-500 to-toxic-500 text-slate-1000 !text-base font-bold hover:from-acid-600 hover:to-toxic-600 focus:ring-slate-300"
"hover:cursor-pointer px-3 py-1 transition bg-gradient-to-r from-acid-500 to-toxic-500 text-slate-1000 !text-base font-bold hover:from-acid-600 hover:to-toxic-600 focus-visible:ring-slate-300"
);
function getSizeClassName(size: Size) {
@@ -0,0 +1,19 @@
import React, { memo } from "react";
export type TooltipProps = {
children: React.ReactNode;
text: string;
};
export const MenuTitleToolTip: React.FC<TooltipProps> = memo((props) => {
return (
<span className="group relative flex">
<span className="pointer-events-none absolute top-0.5 left-12 flex translate-x-0 items-center justify-center whitespace-nowrap rounded bg-slate-700 px-3 py-2 text-sm text-slate-300 opacity-0 shadow transition before:absolute before:-left-2 before:top-full before:-translate-y-[22px] before:border-4 before:border-transparent before:border-r-slate-800 before:shadow before:content-[''] group-hover:opacity-100">
{props.text}
</span>
{props.children}
</span>
);
});
MenuTitleToolTip.displayName = "Menu Title";
@@ -1,4 +1,3 @@
import { QuestionMarkCircleIcon } from "@heroicons/react/20/solid";
import React, { memo } from "react";
export type TooltipProps = {
@@ -9,10 +8,9 @@ export type TooltipProps = {
export const Tooltip: React.FC<TooltipProps> = memo((props) => {
return (
<span className="group relative flex">
<span className=" pointer-events-none absolute -top-10 left-1/2 flex -translate-x-1/2 items-center justify-center whitespace-nowrap rounded bg-slate-1000 px-2 py-1 text-xs text-slate-400 opacity-0 transition delay-300 duration-200 ease-in-out before:absolute before:left-1/2 before:top-full before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-black before:content-[''] group-hover:opacity-100">
<QuestionMarkCircleIcon className="mr-1 flex h-3 w-3" /> {props.text}
<span className="pointer-events-none absolute -top-10 left-1/2 flex -translate-x-1/2 items-center justify-center whitespace-nowrap rounded bg-slate-1000 px-2 py-1 text-xs text-slate-400 opacity-0 transition delay-300 duration-200 ease-in-out before:absolute before:left-1/2 before:top-full before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-black before:content-[''] group-hover:opacity-100">
{props.text}
</span>
{props.children}
</span>
);
@@ -47,7 +47,7 @@ export function WorkflowOnboarding({
</div>
<div className="flex h-full w-full flex-col overflow-hidden bg-slate-800 p-5">
<div className="grid grid-cols-[minmax(0,_1fr),_4rem,_minmax(0,_1fr)]">
<div className="flex h-full flex-col justify-between">
<div className="flex flex-col justify-between">
<Body className="mb-4 text-slate-400">
Add Trigger.dev to an existing Node.js repo.
</Body>
@@ -59,14 +59,14 @@ export function WorkflowOnboarding({
<ArrowTopRightOnSquareIcon className="ml-1 h-4 w-4" />
</PrimaryA>
</div>
<div className="flex flex-col items-center gap-2">
<div className="grid grid-rows-[minmax(0,_1fr)_1rem_minmax(0,_1fr)] items-center justify-items-center gap-2">
<div className="h-full w-px bg-slate-700"></div>
<Body size="small" className="uppercase text-slate-600">
or
</Body>
<div className="h-full w-px bg-slate-700"></div>
</div>
<div className="flex h-full flex-col justify-between">
<div className="flex flex-col justify-between">
<Body className="mb-4 text-slate-400">
Setup a new Node.js project ready for Trigger.dev by
running this command.
+26 -20
View File
@@ -3,30 +3,36 @@ import { hydrateRoot } from "react-dom/client";
import * as Sentry from "@sentry/remix";
import { useEffect } from "react";
import posthog from "posthog-js";
import { LocaleContextProvider } from "./components/LocaleProvider";
hydrateRoot(document, <RemixBrowser />);
hydrateRoot(
document,
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
);
//hack because the type is not exported
type SentryIntegration = (typeof Sentry.defaultIntegrations)[number];
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
tracesSampleRate: 1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.remixRouterInstrumentation(
useEffect,
useLocation,
useMatches
),
}),
//casted because TypeScript is unhappy about the type from PostHog
new posthog.SentryIntegration(
posthog,
"triggerdev",
4504419607052288
) as SentryIntegration,
],
});
Sentry.init({
dsn: "https://bf96820b08004fa4b2e1506f2ac74a14@o4504419574087680.ingest.sentry.io/4504419607052288",
tracesSampleRate: 1,
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.remixRouterInstrumentation(
useEffect,
useLocation,
useMatches
),
}),
//casted because TypeScript is unhappy about the type from PostHog
new posthog.SentryIntegration(
posthog,
"triggerdev",
4504419607052288
) as SentryIntegration,
],
});
}
+112 -12
View File
@@ -1,9 +1,15 @@
import type { EntryContext } from "@remix-run/node";
import { Response } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToString } from "react-dom/server";
import * as MessageBroker from "~/services/messageBroker.server";
import * as Sentry from "~/services/sentry.server";
import { PassThrough } from "stream";
import { renderToPipeableStream } from "react-dom/server";
import { RemixServer } from "@remix-run/react";
import { Response } from "@remix-run/node"; // or cloudflare/deno
import type { EntryContext, Headers } from "@remix-run/node"; // or cloudflare/deno
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { LocaleContextProvider } from "./components/LocaleProvider";
const ABORT_DELAY = 30000;
export default function handleRequest(
request: Request,
@@ -11,17 +17,111 @@ export default function handleRequest(
responseHeaders: Headers,
remixContext: EntryContext
) {
// deepcode ignore Ssti: <This is recommended by Remix>
const markup = renderToString(
// deepcode ignore OR: <All good in the hood>
<RemixServer context={remixContext} url={request.url} />
const acceptLanguage = request.headers.get("accept-language");
const locales = parseAcceptLanguage(acceptLanguage, {
validate: Intl.DateTimeFormat.supportedLocalesOf,
});
// If the request is from a bot, we want to wait for the full
// response to render before sending it to the client. This
// ensures that bots can see the full page content.
if (isbot(request.headers.get("user-agent"))) {
return serveTheBots(
request,
responseStatusCode,
responseHeaders,
remixContext,
locales
);
}
return serveBrowsers(
request,
responseStatusCode,
responseHeaders,
remixContext,
locales
);
}
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
function serveTheBots(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
{
// Use onAllReady to wait for the entire document to be ready
onAllReady() {
responseHeaders.set("Content-Type", "text/html");
let body = new PassThrough();
pipe(body);
resolve(
new Response(body, {
status: responseStatusCode,
headers: responseHeaders,
})
);
},
onShellError(err: unknown) {
reject(err);
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders,
function serveBrowsers(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
) {
return new Promise((resolve, reject) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
{
// use onShellReady to wait until a suspense boundary is triggered
onShellReady() {
responseHeaders.set("Content-Type", "text/html");
let body = new PassThrough();
pipe(body);
resolve(
new Response(body, {
status: didError ? 500 : responseStatusCode,
headers: responseHeaders,
})
);
},
onShellError(err: unknown) {
reject(err);
},
onError(err: unknown) {
didError = true;
console.error(err);
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
+2
View File
@@ -57,6 +57,8 @@ const EnvironmentSchema = z.object({
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
INTEGRATIONS_API_KEY: z.string(),
INTEGRATIONS_API_ORIGIN: z.string(),
CAKEWORK_API_KEY: z.string(),
TRIGGER_WSS_URL: z.string().default("wss://wss.trigger.dev/ws"),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
@@ -0,0 +1,102 @@
import type { CakeworkApi } from "@cakework/client/dist";
import {
CakeworkApiClient,
CakeworkApiEnvironment,
} from "@cakework/client/dist";
import { env } from "~/env.server";
export const cakework = new CakeworkApiClient({
environment: CakeworkApiEnvironment.Production,
xApiKey: env.CAKEWORK_API_KEY,
});
export type GetLogsOptions = {
query?: string;
batch?: number;
pagination?: string;
from?: string | Date;
to?: string | Date;
};
export type GetLogsResponse = CakeworkApi.VmLogs & {
pagination?: string;
from?: string;
to?: string;
};
export type LogLine = {
timestamp: number;
level: string;
message: string;
};
// Doing this using fetch because the cakework SDK doesn't support the options for this endpoint yet
export async function getBuildLogs(
id: string,
options: GetLogsOptions = {}
): Promise<GetLogsResponse> {
return await getLogs(id, "image/build", options);
}
// Doing this using built in node.js http client library because the cakework SDK doesn't support the options for this endpoint yet
export async function getVmLogs(
id: string,
options: GetLogsOptions = {}
): Promise<GetLogsResponse> {
return await getLogs(id, "vm", options);
}
async function getLogs(
id: string,
type: "vm" | "image/build",
options: GetLogsOptions = {}
): Promise<GetLogsResponse> {
const url = new URL(`https://api.cakework.com/v1/${type}/${id}/logs`);
if (options.query) {
url.searchParams.set("query", options.query);
}
if (options.pagination) {
url.searchParams.set("pagination", options.pagination);
}
if (options.from) {
url.searchParams.set(
"from",
typeof options.from === "string"
? options.from
: options.from.toISOString()
);
}
if (options.to) {
url.searchParams.set(
"to",
typeof options.to === "string" ? options.to : options.to.toISOString()
);
}
if (options.batch) {
url.searchParams.set("batch", options.batch.toString());
}
console.log(`GET ${url.toString()}`);
const response = await fetch(url.toString(), {
headers: {
"X-Api-Key": env.CAKEWORK_API_KEY,
Accept: "application/json",
},
});
console.log(
`GET ${url.toString()}: ${response.status} ${response.statusText}`
);
if (!response.ok) {
return { lines: [] };
}
return await response.json();
}
@@ -0,0 +1,76 @@
import type { ProjectDeployment } from ".prisma/client";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { Link } from "@remix-run/react";
import { IntlDate } from "~/components/IntlDate";
import { Body } from "~/components/primitives/text/Body";
import {
deploymentStatusDot,
deploymentStatusIcon,
deploymentStatusTitle,
} from "./deploymentStatus";
export function DeploymentListItem({
deployment,
repo,
isCurrentDeployment,
pathPrefix,
}: {
deployment: ProjectDeployment;
repo: string;
isCurrentDeployment: boolean;
pathPrefix: string;
}) {
let timestamp = deployment.createdAt;
if (deployment.stoppedAt) {
timestamp = deployment.stoppedAt;
} else if (deployment.buildFinishedAt) {
timestamp = deployment.buildFinishedAt;
} else if (deployment.buildStartedAt) {
timestamp = deployment.buildStartedAt;
}
return (
<li className="transition hover:bg-slate-850/40">
<Link to={`${pathPrefix}/${deployment.id}`}>
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-6">
<div className="flex flex-1 items-center justify-between">
<div className="relative flex items-center">
<div className="absolute -left-7 flex h-6 w-6 items-center justify-center rounded-full bg-slate-800">
{deploymentStatusDot(deployment.status)}
</div>
<div className="mr-4 flex h-12 w-12 items-center justify-center rounded-md bg-slate-850 p-2">
{deploymentStatusIcon(deployment.status, "large")}
</div>
<div className="flex flex-col">
<Body className="text-lg font-medium text-slate-100/80 transition hover:text-white">
{deployment.commitMessage} #
{deployment.commitHash.substring(0, 7)}
</Body>
<div className="flex items-center gap-2">
<Body size="small" className="text-slate-500">
{deployment.version}
</Body>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-1.5">
{deploymentStatusDot(deployment.status)}
<Body size="small" className="text-slate-300">
{deploymentStatusTitle(deployment.status)}
</Body>
</div>
<div className="text-xs font-medium text-slate-500">
<IntlDate date={timestamp} timeZone="UTC" />
</div>
</div>
<ChevronRightIcon className="h-4 w-4 text-slate-400" />
</div>
</div>
</div>
</Link>
</li>
);
}
@@ -0,0 +1,118 @@
import type { DeploymentLog } from ".prisma/client";
import classNames from "classnames";
import { LogDate } from "~/components/IntlDate";
import { TertiaryLink } from "~/components/primitives/Buttons";
export function LogOutput({ logs }: { logs: DeploymentLog[] }) {
return (
<pre className="grid max-h-[50px] w-full grid-cols-[repeat(3,_fit-content(800px))_1fr] items-start gap-y-1 gap-x-4 text-sm text-slate-300">
{logs.map((log) => (
<LogItem key={log.id} log={log} />
))}
</pre>
);
}
function LogItem({ log }: { log: DeploymentLog }) {
const parsedMessage = parseLogForTriggerDevLink(log.log);
const logLevel = parseLogLevel(log);
return (
<>
<span>
<LogDate date={log.createdAt} />
</span>
<span
className={classNames(
logLevel === "error"
? "text-rose-500"
: logLevel === "warn"
? "text-amber-500"
: ""
)}
>
{logLevel}
</span>
<span>{parsedMessage.log}</span>
{parsedMessage.action ? (
<TertiaryLink
to={parsedMessage.action.url}
className="sticky -right-4 justify-self-end bg-slate-950 px-3.5"
>
{parsedMessage.action.text}
</TertiaryLink>
) : (
<span></span>
)}
</>
);
}
function parseLogLevel(log: DeploymentLog): string {
// If the log.log has a red flag emoji in it, it's an error
if (log.log.includes("🚨") || log.log.includes("🚩")) {
return "error";
}
return log.level;
}
function parseLogForTriggerDevLink(log: string): {
log: string;
action?: { url: string; text: string };
} {
const runStartedRegex =
/^\[\w+\.\w+\]\s+Run\s+(\w+)\s+started\s+👉\s+View\s+on\s+dashboard:\s+\((\S+)\)\s+\[([^\]]+)\]/;
const runCompleteRegex =
/^\[\w+\.\w+\]\s+Run\s+(\w+)\s+complete\s+👉\s+View\s+on\s+dashboard:\s+\((\S+)\)\s+\[([^\]]+)\]/;
const connectedRegex =
/^\[\w+\.\w+\]\s+✨\s+Connected\s+and\s+listening\s+for\s+events\s+👉\s+View\s+on\s+dashboard:\s+\((\S+)\)\s+\[([^\]]+)\]/;
const runStartedMatch = log.match(runStartedRegex);
if (runStartedMatch) {
const [, id, url, workflowSlug] = runStartedMatch;
const log = `[trigger.dev] Run ${id} started [${workflowSlug}]`;
return {
log,
action: { url: getUrlPath(url), text: "View" },
};
}
const runCompleteMatch = log.match(runCompleteRegex);
if (runCompleteMatch) {
const [, id, url, workflowSlug] = runCompleteMatch;
const log = `[trigger.dev] Run ${id} complete [${workflowSlug}]`;
return {
log,
action: { url: getUrlPath(url), text: "View" },
};
}
const connectedMatch = log.match(connectedRegex);
if (connectedMatch) {
const [, url, workflowSlug] = connectedMatch;
const log = `[trigger.dev] ✨ Connected and listening for events [${workflowSlug}]`;
return {
log,
action: { url: getUrlPath(url), text: "View" },
};
}
return {
log,
};
}
function getUrlPath(url: string) {
const urlObj = new URL(url);
return urlObj.pathname;
}
@@ -0,0 +1,78 @@
import { Popover, Transition } from "@headlessui/react";
import {
ArrowsRightLeftIcon,
ChevronUpDownIcon,
} from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import { Fragment } from "react";
import invariant from "tiny-invariant";
import { BreadcrumbDivider } from "~/components/layout/Header";
import { useCurrentOrganization } from "~/hooks/useOrganizations";
const dimmedClassNames = "text-slate-500";
export function ProjectMenu() {
const currentOrganization = useCurrentOrganization();
invariant(currentOrganization, "Organization must be set");
return (
<>
<BreadcrumbDivider />
<div className="w-full max-w-max">
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`
${open ? "" : ""}
inline-flex items-center justify-between rounded bg-transparent py-2 pl-2.5 pr-2 text-sm text-white hover:bg-slate-800 focus:outline-none`}
>
<ArrowsRightLeftIcon
className={`mr-2 h-5 w-5 ${dimmedClassNames}`}
aria-hidden="true"
/>
<span className="transition">
<span className="truncate">Select Repo</span>
</span>
<ChevronUpDownIcon
className={`${open ? "" : "text-opacity-70"}
ml-1 h-5 w-5 transition duration-150 ease-in-out ${dimmedClassNames}`}
aria-hidden="true"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-0 z-30 mt-3 max-h-[70vh] w-screen min-w-max max-w-xs translate-x-0 transform overflow-hidden overflow-y-auto rounded-lg px-4 sm:px-0">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5">
<div className="relative grid grid-cols-1 gap-y-1 bg-slate-700 py-1">
<Popover.Button
as={Link}
to={`/orgs/${currentOrganization.slug}/projects/new`}
>
<div className="mx-1 flex items-center gap-2 rounded py-2 pl-2.5 transition hover:bg-slate-800">
<PlusIcon
className="h-5 w-5 text-green-500"
aria-hidden="true"
/>
<span className="text-white">New Project</span>
</div>
</Popover.Button>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</>
);
}
@@ -0,0 +1,185 @@
import type { ProjectDeploymentStatus } from ".prisma/client";
import {
StopCircleIcon,
CloudIcon,
ClockIcon,
CloudArrowUpIcon,
CubeTransparentIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
PauseCircleIcon,
} from "@heroicons/react/24/solid";
import classNames from "classnames";
import { ReactNode } from "react";
export function deploymentStatusTitle(status: ProjectDeploymentStatus) {
switch (status) {
case "PENDING":
return "Pending";
case "BUILDING":
return "Building";
case "DEPLOYING":
return "Deploying";
case "DEPLOYED":
return "Deployed";
case "ERROR":
return "Error";
case "CANCELLED":
return "Cancelled";
case "STOPPING":
return "Stopping";
case "STOPPED":
return "Stopped";
}
}
const deploymentStatusDotStyles = "h-2.5 w-2.5 rounded-full";
export function deploymentStatusDot(
status: ProjectDeploymentStatus
): ReactNode {
switch (status) {
case "PENDING":
return (
<div
className={classNames(
deploymentStatusDotStyles,
"animate-pulse bg-blue-500"
)}
></div>
);
case "BUILDING":
return (
<div
className={classNames(
deploymentStatusDotStyles,
"animate-pulse bg-blue-500"
)}
></div>
);
case "DEPLOYING":
return (
<div
className={classNames(
deploymentStatusDotStyles,
"animate-pulse bg-blue-500"
)}
></div>
);
case "DEPLOYED":
return (
<div
className={classNames(deploymentStatusDotStyles, "bg-green-500")}
></div>
);
case "ERROR":
return (
<div
className={classNames(deploymentStatusDotStyles, "bg-rose-500")}
></div>
);
case "CANCELLED":
return (
<div
className={classNames(deploymentStatusDotStyles, "bg-slate-400")}
></div>
);
case "STOPPING":
return (
<div
className={classNames(
deploymentStatusDotStyles,
"animate-pulse bg-slate-400"
)}
></div>
);
case "STOPPED":
return (
<div
className={classNames(deploymentStatusDotStyles, "bg-slate-400")}
></div>
);
}
}
export function deploymentStatusIcon(
status: ProjectDeploymentStatus,
iconSize: "small" | "large"
): ReactNode {
const largeClasses = "relative h-7 w-7";
const smallClasses = "relative h-4 w-4";
switch (status) {
case "PENDING":
return (
<ClockIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-blue-500"
)}
/>
);
case "BUILDING":
return (
<CubeTransparentIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-blue-500"
)}
/>
);
case "DEPLOYING":
return (
<CloudArrowUpIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-blue-500"
)}
/>
);
case "DEPLOYED":
return (
<CloudIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-blue-500"
)}
/>
);
case "ERROR":
return (
<ExclamationTriangleIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-rose-500"
)}
/>
);
case "CANCELLED":
return (
<NoSymbolIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-slate-400"
)}
/>
);
case "STOPPING":
return (
<PauseCircleIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-slate-400"
)}
/>
);
case "STOPPED":
return (
<StopCircleIcon
className={classNames(
iconSize === "small" ? smallClasses : largeClasses,
"relative text-slate-400"
)}
/>
);
}
}
@@ -1,10 +1,7 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import {
getAppInstallation,
oauthApp,
octokit,
} from "~/services/github/githubApp.server";
import { getAppInstallation } from "~/features/ee/projects/github/githubApp.server";
import { taskQueue } from "../../../../services/messageBroker.server";
export class AppInstallationCallback {
#prismaClient: PrismaClient;
@@ -14,18 +11,14 @@ export class AppInstallationCallback {
}
public async call({
code,
state,
installation_id,
setup_action,
}: {
code: string;
state: string;
installation_id: string;
setup_action: "install" | "update";
}) {
if (!oauthApp || !octokit) {
return;
}
const attempt =
await this.#prismaClient.gitHubAppAuthorizationAttempt.findUnique({
where: {
@@ -37,6 +30,10 @@ export class AppInstallationCallback {
return;
}
if (attempt.authorizationId) {
return attempt.redirectTo;
}
const installation = await getAppInstallation({
installation_id: Number(installation_id),
});
@@ -45,7 +42,33 @@ export class AppInstallationCallback {
return;
}
const { authentication } = await oauthApp.createToken({ code, state });
const existingAuthorization =
await this.#prismaClient.gitHubAppAuthorization.findUnique({
where: {
installationId: installation.id,
},
});
if (existingAuthorization) {
await this.#prismaClient.gitHubAppAuthorizationAttempt.delete({
where: {
id: attempt.id,
},
});
await this.#prismaClient.gitHubAppAuthorization.update({
where: {
id: existingAuthorization.id,
},
data: {
events: installation.events,
permissions: installation.permissions,
repositorySelection: installation.repository_selection,
},
});
return attempt.redirectTo;
}
const authorization =
await this.#prismaClient.gitHubAppAuthorization.create({
@@ -55,18 +78,6 @@ export class AppInstallationCallback {
id: attempt.userId,
},
},
organization: {
connect: {
id: attempt.organizationId,
},
},
token: authentication.token,
// @ts-ignore
tokenExpiresAt: new Date(authentication.expiresAt),
// @ts-ignore
refreshToken: authentication.refreshToken,
// @ts-ignore
refreshTokenExpiresAt: new Date(authentication.refreshTokenExpiresAt),
installationId: installation.id,
account: installation.account,
accountName: installation.account.login,
@@ -79,13 +90,6 @@ export class AppInstallationCallback {
accountType:
installation.account?.type === "User" ? "USER" : "ORGANIZATION",
},
include: {
organization: {
select: {
slug: true,
},
},
},
});
await this.#prismaClient.gitHubAppAuthorizationAttempt.delete({
@@ -94,6 +98,10 @@ export class AppInstallationCallback {
},
});
return { authorization, templateId: attempt.templateId };
await taskQueue.publish("GITHUB_APP_INSTALLATION_CREATED", {
id: authorization.id,
});
return attempt.redirectTo;
}
}
@@ -0,0 +1,316 @@
import type { Endpoints } from "@octokit/types";
import type { PushEvent } from "@octokit/webhooks-types";
import { verify } from "@octokit/webhooks-methods";
import { sign as signJWT } from "jsonwebtoken";
import { z } from "zod";
import { env } from "~/env.server";
import type { EmitterWebhookEventName } from "@octokit/webhooks";
import { taskQueue } from "../../../../services/messageBroker.server";
export async function verifyAndReceiveWebhook(request: Request) {
if (!env.GITHUB_APP_WEBHOOK_SECRET) {
return new Response("", { status: 200 });
}
const payload = await request.text();
const headers = Object.fromEntries(request.headers.entries());
console.log(headers);
const id = headers["x-github-delivery"];
const hookName = headers["x-github-event"];
const signature = headers["x-hub-signature"];
const results = await verify(
env.GITHUB_APP_WEBHOOK_SECRET,
payload,
signature
);
if (!results) {
console.error(`[webhooks.github] Invalid signature`, {
id,
hookName,
signature,
});
return new Response("", { status: 200 });
}
const parsedPayload = JSON.parse(payload);
const name = (
parsedPayload.action ? `${hookName}.${parsedPayload.action}` : hookName
) as EmitterWebhookEventName;
console.log(`[webhooks.github] Received event`, {
id,
name,
payload: parsedPayload,
});
await handleGithubEvent({
id,
name,
payload: parsedPayload,
});
return new Response("", { status: 200 });
}
async function handleGithubEvent<TName extends EmitterWebhookEventName>({
id,
name,
payload,
}: {
id: string;
name: TName;
payload: any;
}) {
switch (name) {
case "installation.deleted": {
await taskQueue.publish("GITHUB_APP_INSTALLATION_DELETED", {
id: payload.installation.id,
});
}
case "push": {
const push = payload as PushEvent;
// Only on pushes to a branch
if (!push.ref.startsWith("refs/heads/")) {
return;
}
const branch = push.ref.replace("refs/heads/", "");
await taskQueue.publish("GITHUB_PUSH", {
branch: branch,
commitSha: push.after,
repository: push.repository.full_name,
});
}
}
}
type GetAppInstallationEndpoint =
Endpoints["GET /app/installations/{installation_id}"];
export async function getAppInstallation({
installation_id,
}: GetAppInstallationEndpoint["parameters"]): Promise<
GetAppInstallationEndpoint["response"]["data"]
> {
const jwt = createSignedGitHubAppJWT();
const response = await fetch(
`https://api.github.com/app/installations/${installation_id}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
throw new Error(
`Failed to get installation ${installation_id}: ${response.statusText}`
);
}
return await response.json();
}
type CreateInstallationAccessTokenEndpoint =
Endpoints["POST /app/installations/{installation_id}/access_tokens"];
export type CreateInstallationAccessTokenResponse =
CreateInstallationAccessTokenEndpoint["response"]["data"];
export async function createInstallationAccessToken(
url: string
): Promise<CreateInstallationAccessTokenResponse> {
const jwt = createSignedGitHubAppJWT();
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/vnd.github.v3+json",
Authorization: `Bearer ${jwt}`,
},
});
if (!response.ok) {
throw new Error(
`Failed to create access token for installation: ${response.statusText}`
);
}
return await response.json();
}
export type GetInstallationRepositoriesEndpoint =
Endpoints["GET /installation/repositories"];
export type GetInstallationRepositoriesResponse =
GetInstallationRepositoriesEndpoint["response"]["data"];
export async function getInstallationRepositories(
token: string
): Promise<GetInstallationRepositoriesResponse["repositories"]> {
// Continuously fetch pages until we have all repositories
let page = 1;
const perPage = 100;
const repositories: GetInstallationRepositoriesResponse["repositories"] = [];
while (true) {
const response = await getInstallationRepositoriesPage(
token,
page,
perPage
);
repositories.push(...response.repositories);
if (response.repositories.length < perPage) {
break;
}
page++;
}
return repositories;
}
async function getInstallationRepositoriesPage(
token: string,
page = 1,
perPage = 100
): Promise<GetInstallationRepositoriesResponse> {
const response = await fetch(
`https://api.github.com/installation/repositories?per_page=${perPage}&page=${page}`,
{
method: "GET",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
throw new Error(
`Failed to get installation repositories: ${response.statusText}`
);
}
return await response.json();
}
export async function getRepositoryContent(
token: string,
repo: string,
path: string
): Promise<string | undefined> {
const response = await fetch(
`https://api.github.com/repos/${repo}/contents/${path}`,
{
method: "GET",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.raw",
},
}
);
if (response.status === 404) {
return;
}
if (!response.ok) {
throw new Error(`Failed to get repository content: ${response.statusText}`);
}
return await response.text();
}
export type GetCommitEndpoint =
Endpoints["GET /repos/{owner}/{repo}/commits/{ref}"];
export type GetCommitResponse = GetCommitEndpoint["response"]["data"];
export type GitHubCommit = GetCommitResponse;
export async function getCommit(
token: string,
repo: string,
ref: string
): Promise<GitHubCommit> {
const response = await fetch(
`https://api.github.com/repos/${repo}/commits/${ref}`,
{
method: "GET",
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
}
);
if (!response.ok) {
throw new Error(`Failed to get commit: ${response.statusText}`);
}
return await response.json();
}
export const AccountSchema = z.object({
login: z.string(),
id: z.number(),
node_id: z.string(),
name: z.string().optional(),
email: z.string().optional().nullable(),
avatar_url: z.string(),
gravatar_id: z.string(),
url: z.string(),
html_url: z.string(),
followers_url: z.string(),
following_url: z.string(),
gists_url: z.string(),
starred_url: z.string(),
subscriptions_url: z.string(),
organizations_url: z.string(),
repos_url: z.string(),
events_url: z.string(),
received_events_url: z.string(),
type: z.union([
z.literal("Bot"),
z.literal("User"),
z.literal("Organization"),
]),
site_admin: z.boolean(),
});
function createSignedGitHubAppJWT() {
if (!env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY) {
throw new Error("Missing GitHub App ID or private key");
}
const privateKey = Buffer.from(env.GITHUB_APP_PRIVATE_KEY, "base64").toString(
"utf8"
);
const unsignedJWT = {
iat: Math.floor(Date.now() / 1000) - 10,
exp: Math.floor(Date.now() / 1000) + 10 * 60,
iss: env.GITHUB_APP_ID,
};
const signedJWT = signJWT(unsignedJWT, privateKey, {
algorithm: "RS256",
});
return signedJWT;
}
@@ -0,0 +1,51 @@
import type { GitHubAppAuthorization } from ".prisma/client";
import { prisma } from "~/db.server";
import { createInstallationAccessToken } from "./githubApp.server";
export type GitHubAppAuthorizationWithValidToken = GitHubAppAuthorization & {
installationAccessToken: string;
installationAccessTokenExpiresAt: Date;
};
export async function refreshInstallationAccessToken(
authorizationOrId: GitHubAppAuthorization | string
): Promise<GitHubAppAuthorizationWithValidToken> {
const authorization =
typeof authorizationOrId === "string"
? await prisma.gitHubAppAuthorization.findUnique({
where: {
id: authorizationOrId,
},
})
: authorizationOrId;
if (!authorization) {
throw new Error("App authorization not found");
}
// Make sure the access token is not expired, or less than 10 minutes from expiring
const accessTokenExpired =
!authorization.installationAccessTokenExpiresAt ||
authorization.installationAccessTokenExpiresAt.getTime() <
Date.now() + 10 * 60 * 1000;
if (accessTokenExpired) {
const accessToken = await createInstallationAccessToken(
authorization.accessTokensUrl
);
const updatedAppAuthorization = await prisma.gitHubAppAuthorization.update({
where: {
id: authorization.id,
},
data: {
installationAccessToken: accessToken.token,
installationAccessTokenExpiresAt: new Date(accessToken.expires_at),
},
});
return updatedAppAuthorization as GitHubAppAuthorizationWithValidToken;
}
return authorization as GitHubAppAuthorizationWithValidToken;
}
@@ -11,34 +11,25 @@ export class StartAppInstallation {
public async call({
userId,
organizationSlug,
templateId,
redirectTo,
authorizationId,
}: {
userId: string;
organizationSlug: string;
templateId?: string;
redirectTo: string;
authorizationId?: string;
}) {
if (!env.GITHUB_APP_NAME) {
return;
}
const organization = await this.#prismaClient.organization.findUnique({
where: {
slug: organizationSlug,
},
});
if (!organization) {
return;
}
const attempt = await prisma.gitHubAppAuthorizationAttempt.create({
data: {
organizationId: organization.id,
userId,
templateId,
},
});
const attempt =
await this.#prismaClient.gitHubAppAuthorizationAttempt.create({
data: {
userId,
redirectTo,
authorizationId,
},
});
return `https://github.com/apps/${env.GITHUB_APP_NAME}/installations/new?state=${attempt.id}`;
}
@@ -0,0 +1,267 @@
import type {
RepositoryProject,
RuntimeEnvironment,
ProjectDeployment,
} from ".prisma/client";
import { z } from "zod";
import { parse as parseYAML } from "yaml";
import { getRepositoryContent } from "~/features/ee/projects/github/githubApp.server";
import { refreshInstallationAccessToken } from "~/features/ee/projects/github/refreshInstallationAccessToken.server";
import { env } from "~/env.server";
import { prisma } from "~/db.server";
export async function findProjectByRepo(name: string) {
return await prisma.repositoryProject.findUnique({
where: {
name,
},
include: {
organization: {
include: {
environments: true,
},
},
},
});
}
export async function findProjectById(id: string) {
return await prisma.repositoryProject.findUnique({
where: {
id,
},
include: {
organization: {
include: {
environments: true,
},
},
},
});
}
// Generates a new version number for a deployment
// The version numbers are in the following format:
// YYYYMMDD.NUMBER
//
// So for example, the first deploy on March 7th, 2023 would be:
// 20230307.1
// The second deploy on March 7th, 2023 would be:
// 20230307.2
//
// The version number is used to determine the order of deployments
export async function getNextDeploymentVersion(
projectId: string
): Promise<string> {
const latestDeploymentVersion = await getLatestDeploymentVersion(projectId);
const generateCurrentDatePart = () => {
return new Date().toISOString().slice(0, 10).replace(/-/g, "");
};
if (!latestDeploymentVersion) {
return `${generateCurrentDatePart()}.1`;
}
const [datePart, numberPart] = latestDeploymentVersion.split(".");
if (datePart === generateCurrentDatePart()) {
return `${datePart}.${Number(numberPart) + 1}`;
}
return `${generateCurrentDatePart()}.1`;
}
async function getLatestDeploymentVersion(
projectId: string
): Promise<string | undefined> {
const latestDeployment = await prisma.projectDeployment.findFirst({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
select: {
version: true,
},
});
if (!latestDeployment) {
return;
}
return latestDeployment.version;
}
export function statusTextForBuilding(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Building ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
export function statusTextForDeploying(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Deploying ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
export function statusTextForDeployed(
deployment: ProjectDeployment,
project: RepositoryProject
) {
return `Deployed ${project.name}#${project.branch} at ${truncateSha(
deployment.commitHash
)}: "${deployment.commitMessage}" by ${deployment.committer}`;
}
function truncateSha(sha: string) {
return sha.substring(0, 7);
}
export function repositoryProjectReadyToDeploy(project: RepositoryProject) {
return project.status === "PENDING" && hasAllEnvVars(project);
}
export function buildEnvVars(
deployment: ProjectDeployment,
project: RepositoryProject,
environment: RuntimeEnvironment
): Record<string, string> {
const envVars = BluePrintEnvVarsSchema.parse(project.envVars);
const result = envVars.reduce((acc, envVar) => {
if (envVar.key === "TRIGGER_API_KEY") {
return {
...acc,
[envVar.key]: environment.apiKey,
};
}
if (!envVar.value) {
return acc;
}
return {
...acc,
[envVar.key]: envVar.value,
};
}, {});
return {
...result,
TRIGGER_PROJECT_ID: project.id,
TRIGGER_DEPLOYMENT_ID: deployment.id,
TRIGGER_WSS_URL: env.TRIGGER_WSS_URL,
};
}
export function hasAllEnvVars(project: RepositoryProject) {
const envVars = BluePrintEnvVarsSchema.parse(project.envVars);
// Removing the TRIGGER_API_KEY environment var, are there any other env vars that don't have a value?
return (
envVars
.filter((envVar) => envVar.key !== "TRIGGER_API_KEY")
.filter((envVar) => !envVar.value).length === 0
);
}
export function parseEnvVars(project: RepositoryProject) {
return BluePrintEnvVarsSchema.parse(project.envVars).filter(
(envVar) => envVar.key !== "TRIGGER_API_KEY"
);
}
export async function serviceDefinitionFromRepository(
appAuthorizationId: string,
repoName: string
) {
const appAuthorization = await refreshInstallationAccessToken(
appAuthorizationId
);
const renderYamlContent = await getRepositoryContent(
appAuthorization.installationAccessToken,
repoName,
"render.yaml"
);
if (!renderYamlContent) {
return;
}
const rawRenderYaml = safeParseYAML(renderYamlContent);
if (!rawRenderYaml) {
return;
}
const blueprint = BlueprintSchema.safeParse(rawRenderYaml);
if (!blueprint.success) {
return;
}
// Find a worker service with env = node and an envVar with the name TRIGGER_API_KEY
const workerService = blueprint.data.services.find(
(service) =>
service.type === "worker" &&
service.env === "node" &&
service.envVars.find((envVar) => envVar.key === "TRIGGER_API_KEY")
);
return workerService;
}
function safeParseYAML(content: string) {
try {
return parseYAML(content);
} catch (error) {
return;
}
}
const BluePrintEnvVarsSchema = z.array(
z
.object({
key: z.string(),
value: z.any(),
sync: z.boolean().default(true),
})
.passthrough()
);
const BlueprintServiceSchema = z
.object({
name: z.string(),
type: z.enum(["web", "worker", "pserv", "cron"]),
env: z.enum([
"node",
"go",
"python",
"ruby",
"php",
"java",
"docker",
"rust",
"static",
]),
buildCommand: z.string().optional(),
startCommand: z.string().optional(),
autoDeploy: z.boolean().default(true),
envVars: BluePrintEnvVarsSchema,
})
.passthrough();
export type BlueprintService = z.infer<typeof BlueprintServiceSchema>;
const BlueprintSchema = z.object({
services: z.array(BlueprintServiceSchema),
});
@@ -0,0 +1,27 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
export class DeploymentListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string, projectId: string) {
const deployments = await this.#prismaClient.projectDeployment.findMany({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
take: 30,
});
return {
organizationSlug,
deployments,
};
}
}
@@ -0,0 +1,43 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
export class DeploymentPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string, projectId: string, deployId: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id: deployId,
},
});
if (!deployment) {
throw new Error("Deployment not found");
}
const logType = ["STOPPED", "STOPPING", "DEPLOYED"].includes(
deployment.status
)
? "MACHINE"
: "BUILD";
const logs = await this.#prismaClient.deploymentLog.findMany({
where: {
deploymentId: deployId,
logType: logType,
},
orderBy: [{ createdAt: "asc" }, { logNumber: "asc" }],
take: 100,
});
return {
organizationSlug,
deployment,
logs,
};
}
}
@@ -0,0 +1,168 @@
import type { GitHubAppAuthorization } from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getRepositoryFromMetadata } from "~/models/workflow.server";
import type { CreateInstallationAccessTokenResponse } from "~/features/ee/projects/github/githubApp.server";
import { getInstallationRepositories } from "~/features/ee/projects/github/githubApp.server";
import { refreshInstallationAccessToken } from "~/features/ee/projects/github/refreshInstallationAccessToken.server";
export type InstallationRepository = NonNullable<
CreateInstallationAccessTokenResponse["repositories"]
>[number];
export type RepositoryWithStatus = {
repository: InstallationRepository;
status: "relevant" | "unknown";
appAuthorizationId: string;
projectId?: string;
};
export class NewProjectPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(userId: string, organizationSlug: string) {
const appAuthorizations =
await this.#prismaClient.gitHubAppAuthorization.findMany({
where: {
user: {
id: userId,
},
},
});
const projects = await this.#prismaClient.repositoryProject.findMany({
where: {
organization: {
slug: organizationSlug,
},
},
select: {
name: true,
id: true,
},
});
const relevantRepositories =
await this.#gatherRepositoriesFromWorkflowsInOrganization(
organizationSlug
);
const repositories = this.#findRepositoriesForAuthorizations(
appAuthorizations,
projects,
relevantRepositories
);
return {
appAuthorizations,
redirectTo: `/orgs/${organizationSlug}/projects/new`,
repositories,
};
}
async #findRepositoriesForAuthorizations(
authorizations: GitHubAppAuthorization[],
projects: Array<{ name: string; id: string }>,
relevantRepositories: string[] = []
): Promise<
Array<{
repositories: Array<RepositoryWithStatus>;
authorization: GitHubAppAuthorization;
}>
> {
const repositories: Array<{
repositories: Array<RepositoryWithStatus>;
authorization: GitHubAppAuthorization;
}> = [];
for (const authorization of authorizations) {
const validAuthorization = await refreshInstallationAccessToken(
authorization
);
const installationRepositories = await getInstallationRepositories(
validAuthorization.installationAccessToken
);
const repositoriesWithStatus = installationRepositories.map(
(repository) => {
const status = relevantRepositories.includes(repository.full_name)
? ("relevant" as const)
: ("unknown" as const);
const project = projects.find(
(project) => project.name === repository.full_name
);
return {
repository,
status,
appAuthorizationId: authorization.id,
projectId: project?.id,
};
}
);
const sortedRepos = repositoriesWithStatus.sort((a, b) => {
if (a.status === "relevant" && b.status === "unknown") {
return -1;
} else if (a.status === "unknown" && b.status === "relevant") {
return 1;
} else {
if (!a.repository.pushed_at) {
return 1;
}
if (!b.repository.pushed_at) {
return -1;
}
return (
new Date(b.repository.pushed_at).getTime() -
new Date(a.repository.pushed_at).getTime()
);
}
});
repositories.push({
repositories: sortedRepos,
authorization,
});
}
// Sort by the number of relevant repositories
return repositories.sort((a, b) => {
const aRelevant = a.repositories.filter(
(repository) => repository.status === "relevant"
).length;
const bRelevant = b.repositories.filter(
(repository) => repository.status === "relevant"
).length;
return bRelevant - aRelevant;
});
}
async #gatherRepositoriesFromWorkflowsInOrganization(
organizationSlug: string
) {
const workflows = await this.#prismaClient.workflow.findMany({
where: {
organization: {
slug: organizationSlug,
},
},
select: {
metadata: true,
},
});
return workflows
.map((workflow) => getRepositoryFromMetadata(workflow.metadata))
.filter(Boolean);
}
}
@@ -0,0 +1,43 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
export type ProjectListItem = Awaited<
ReturnType<ProjectListPresenter["projectsForOrg"]>
>[number];
export class ProjectListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string) {
const projects = await this.projectsForOrg(organizationSlug);
return {
organizationSlug,
projects,
};
}
private async projectsForOrg(organizationSlug: string) {
return await this.#prismaClient.repositoryProject.findMany({
where: {
organization: {
slug: organizationSlug,
},
status: {
not: "DISABLED",
},
},
orderBy: {
createdAt: "desc",
},
include: {
currentDeployment: true,
},
take: 30,
});
}
}
@@ -0,0 +1,37 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
export class ProjectLogsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string, projectId: string) {
const { currentDeployment } =
await this.#prismaClient.repositoryProject.findUniqueOrThrow({
where: {
id: projectId,
},
include: {
currentDeployment: {
include: {
logs: {
where: {
logType: "MACHINE",
},
orderBy: [{ createdAt: "asc" }, { logNumber: "asc" }],
take: 100,
},
},
},
},
});
return {
currentDeployment,
logs: currentDeployment?.logs ?? [],
};
}
}
@@ -0,0 +1,51 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getEnvironmentForOrganization } from "~/models/runtimeEnvironment.server";
import { WorkflowsPresenter } from "~/presenters/workflowsPresenter.server";
export class ProjectOverviewPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(organizationSlug: string, projectId: string) {
const liveEnvironment = await getEnvironmentForOrganization(
organizationSlug,
LIVE_ENVIRONMENT
);
if (!liveEnvironment) {
throw new Error("No live environment found");
}
const workflowsPresenter = new WorkflowsPresenter(this.#prismaClient);
const workflows = await workflowsPresenter.data(
{
repositoryProject: {
id: projectId,
},
},
liveEnvironment.id
);
const deployments = await this.#prismaClient.projectDeployment.findMany({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
take: 10,
});
return {
workflows,
organizationSlug,
deployments,
};
}
}
@@ -0,0 +1,28 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { eventStream } from "remix-utils";
import { z } from "zod";
import { requireUserId } from "~/services/session.server";
import { findProjectById } from "../models/repositoryProject.server";
export async function loader({ request, params }: LoaderArgs) {
await requireUserId(request);
const { id } = z.object({ id: z.string() }).parse(params);
const project = await findProjectById(id);
if (!project) {
return new Response("Not found", { status: 404 });
}
return eventStream(request.signal, (send) => {
const interval = setInterval(() => {
// Get the updatedAt date from the projects database, and send it to the client if it's different from the last one
send({ event: "update", data: new Date().toISOString() });
}, 5000);
return function clear() {
clearInterval(interval);
};
});
}
@@ -0,0 +1,72 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { eventStream } from "remix-utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
await requireUserId(request);
const { id } = z.object({ id: z.string() }).parse(params);
const deployment = await findDeploymentWithLatestLog(id);
if (!deployment) {
return new Response("Not found", { status: 404 });
}
let latestLogAt: number = deployment.polls[0]?.to.getTime() ?? 0;
let lastUpdatedAt: number = deployment.updatedAt.getTime();
let stopped = false;
return eventStream(request.signal, (send) => {
const pinger = setInterval(() => {
send({ event: "ping", data: new Date().toISOString() });
}, 1000);
const interval = setInterval(() => {
findDeploymentWithLatestLog(id).then((deployment) => {
if (stopped) return;
if (deployment) {
if (lastUpdatedAt !== deployment.updatedAt.getTime()) {
send({ event: "update", data: deployment.updatedAt.toISOString() });
} else if (
deployment.polls[0] &&
latestLogAt !== deployment.polls[0].to.getTime()
) {
send({
event: "update",
data: deployment.polls[0].to.toISOString(),
});
}
latestLogAt = deployment.polls[0]?.to.getTime() ?? 0;
lastUpdatedAt = deployment.updatedAt.getTime();
}
});
}, 1000);
return function clear() {
stopped = true;
clearInterval(interval);
clearInterval(pinger);
};
});
}
async function findDeploymentWithLatestLog(id: string) {
return await prisma.projectDeployment.findUnique({
where: { id },
include: {
polls: {
take: 1,
orderBy: { createdAt: "desc" },
where: {
filteredLogsCount: { gt: 0 },
},
},
},
});
}
@@ -0,0 +1,25 @@
import { Outlet } from "@remix-run/react";
import { LoaderArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { requireUser } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
const user = await requireUser(request);
const organizationSlug = params.organizationSlug as string;
if (!user.featureCloud) {
const url = new URL(request.url);
if (!url.pathname.endsWith("/coming-soon")) {
return redirect(`/orgs/${organizationSlug}/projects/coming-soon`, {
status: 302,
});
}
}
return {};
}
export default function ProjectsLayout() {
return <Outlet />;
}
@@ -0,0 +1,84 @@
import { Outlet } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import type { UseDataFunctionReturn } from "remix-typedjson";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import {
ProjectSideMenu,
SideMenuContainer,
} from "~/components/navigation/SideMenu";
import { prisma } from "~/db.server";
import { hydrateObject, useMatchesData } from "~/utils";
import {
hasAllEnvVars,
parseEnvVars,
} from "../../models/repositoryProject.server";
export async function loader({ params }: LoaderArgs) {
const { organizationSlug, projectP } = params;
invariant(organizationSlug, "organizationSlug not found");
invariant(projectP, "projectP not found");
const project = await prisma.repositoryProject.findFirstOrThrow({
where: {
id: projectP,
organization: {
slug: organizationSlug,
},
},
include: {
currentDeployment: true,
},
});
const envVars = parseEnvVars(project);
const needsEnvVars = !hasAllEnvVars(project);
return typedjson({
project,
organizationSlug,
needsEnvVars,
envVars,
});
}
export default function ProjectLayout() {
const { project, organizationSlug } = useTypedLoaderData<typeof loader>();
return (
<SideMenuContainer>
<ProjectSideMenu
project={project}
backPath={`/orgs/${organizationSlug}`}
/>
<div className="grid w-full grid-rows-[3.6rem_auto] overflow-y-auto bg-slate-850">
<Header context="projects" />
<Container>
<Outlet />
</Container>{" "}
</div>
</SideMenuContainer>
);
}
export function useCurrentProject() {
const routeMatch = useMatchesData(
"routes/__app/orgs/$organizationSlug/__org/projects/$projectP"
);
if (!routeMatch || !routeMatch.data.project) {
throw new Error("Calling useCurrentProject outside of a project route");
}
const result = hydrateObject<UseDataFunctionReturn<typeof loader>>(
routeMatch.data
);
return result;
}
export type CurrentProjectLoaderData = ReturnType<typeof useCurrentProject>;
export type CurrentProject = CurrentProjectLoaderData["project"];
@@ -0,0 +1,258 @@
import { ArrowTopRightOnSquareIcon, StopIcon } from "@heroicons/react/20/solid";
import { Form, useRevalidator, useTransition } from "@remix-run/react";
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
import classNames from "classnames";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { z } from "zod";
import { IntlDate } from "~/components/IntlDate";
import { Panel } from "~/components/layout/Panel";
import {
SecondaryButton,
SecondaryLink,
} from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { Header1 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { deploymentStatusTitle } from "~/features/ee/projects/components/deploymentStatus";
import { DeploymentPresenter } from "~/features/ee/projects/presenters/deploymentPresenter.server";
import { CancelProjectDeployment } from "~/features/ee/projects/services/cancelProjectDeployment.server";
import { StopProjectDeployment } from "~/features/ee/projects/services/stopProjectDeployment.server";
import { useCurrentProject } from "../../$projectP";
import { LogOutput } from "../../../../components/LogOutput";
export async function loader({ request, params }: LoaderArgs) {
const { projectP, organizationSlug, deployId } = z
.object({
projectP: z.string(),
organizationSlug: z.string(),
deployId: z.string(),
})
.parse(params);
const presenter = new DeploymentPresenter();
return typedjson(await presenter.data(organizationSlug, projectP, deployId));
}
export async function action({ request, params }: ActionArgs) {
const { deployId } = z
.object({
deployId: z.string(),
})
.parse(params);
const formPayload = Object.fromEntries(await request.formData());
if (formPayload.action === "cancel") {
const service = new CancelProjectDeployment();
await service.call(deployId);
}
if (formPayload.action === "stop") {
const service = new StopProjectDeployment();
await service.call(deployId);
}
return { action: formPayload.action };
}
export default function DeploymentPage() {
const { project } = useCurrentProject();
const { deployment, logs } = useTypedLoaderData<typeof loader>();
const transitionData = useTransition();
const events = useEventSource(`/resources/deploys/${deployment.id}/stream`, {
event: "update",
});
const revalidator = useRevalidator();
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
const disabled = deployment.status !== "DEPLOYED";
let action: "cancel" | "stop" | undefined;
let actionConfirm: string | undefined;
const actionLoading =
(transitionData.state === "submitting" &&
transitionData.type === "actionSubmission") ||
(transitionData.state === "loading" &&
transitionData.type === "actionReload");
switch (deployment.status) {
case "PENDING": {
action = "cancel";
actionConfirm = "Are you sure you want to cancel this deployment?";
break;
}
case "BUILDING":
case "DEPLOYED": {
action = "stop";
actionConfirm = "Are you sure you want to stop this deployment?";
break;
}
}
return (
<div className="flex h-full flex-col">
<div className="flex items-start justify-between">
<Header1 className="mb-6">Deployment</Header1>
<div className="flex gap-2">
{action && (
<Form
method="post"
onSubmit={(e) =>
!confirm(actionConfirm ?? "Are you sure?") && e.preventDefault()
}
>
<SecondaryButton
disabled={actionLoading}
type="submit"
name="action"
value={action}
>
{action === "stop" ? (
<>
{actionLoading ? (
<>
<Spinner className="-ml-1 h-3 w-3" />
Stopping deployment
</>
) : (
<>
<StopIcon className="-ml-1 h-3 w-3 text-rose-500" />
Stop deployment
</>
)}
</>
) : (
<>
{actionLoading ? (
<>
<Spinner className="-ml-1 h-3 w-3" />
Cancelling deployment
</>
) : (
<>
<StopIcon className="-ml-1 h-3 w-3 text-rose-500" />
Cancel deployment
</>
)}
</>
)}
</SecondaryButton>
</Form>
)}
<SecondaryLink
to="#"
className={classNames(
disabled ? "pointer-events-none opacity-40" : ""
)}
>
View Build Logs
</SecondaryLink>
</div>
</div>
<SubTitle>Deploy Summary</SubTitle>
<Panel className="mb-6 !p-4">
<ul className="mb-4 grid grid-cols-4">
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Status
</Body>
<div className="flex items-center gap-2">
{deployment.status === "DEPLOYING" ||
deployment.status === "PENDING" ||
deployment.status === "BUILDING" ? (
<Spinner />
) : (
<></>
)}
<Body className={deploySummaryValueStyles}>
{deploymentStatusTitle(deployment.status)}
</Body>
</div>
</li>
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Started
</Body>
<Body className={deploySummaryValueStyles}>
<IntlDate date={deployment.createdAt} />
</Body>
</li>
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Environment
</Body>
<Body className={deploySummaryValueStyles}>Live</Body>
</li>
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Version
</Body>
<Body className={deploySummaryValueStyles}>
{deployment.version}
</Body>
</li>
</ul>
<div className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Commit
</Body>
<a
href={`https://github.com/${project.name}/commit/${deployment.commitHash}`}
target="_blank"
className="flex max-w-max items-center gap-1.5 text-slate-300 transition hover:text-slate-100"
>
{deployment.commitHash.slice(0, 12)}
<span>
"{deployment.commitMessage}" by {deployment.committer}
</span>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</a>
</div>
</Panel>
<div className="mb-2 flex items-center justify-between">
<SubTitle className="mb-0">Deploy logs</SubTitle>
<div className="flex items-center gap-4">
{revalidator.state === "loading" && (
<div className="flex items-center gap-1.5">
<Spinner className="h-4 w-4" />
<Body size="small" className="text-slate-400">
Loading new logs
</Body>
</div>
)}
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500"></span>
<Body size="small" className="text-slate-400">
Live reloading
</Body>
</div>
</div>
</div>
<div className="flex flex-auto overflow-auto rounded-md bg-slate-950 p-4">
<LogOutput logs={logs} />
</div>
</div>
);
}
export const deploySummaryGridStyles = "flex flex-col gap-1";
export const deploySummaryLabelStyles =
"uppercase text-slate-400 tracking-wide";
export const deploySummaryValueStyles = "text-slate-300";
@@ -0,0 +1,59 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { List } from "~/components/layout/List";
import { PanelWarning } from "~/components/layout/PanelInfo";
import { TertiaryLink } from "~/components/primitives/Buttons";
import { Body } from "~/components/primitives/text/Body";
import { Title } from "~/components/primitives/text/Title";
import { useCurrentProject } from "../../$projectP";
import { DeploymentListItem } from "../../../../components/DeploymentListItem";
import { DeploymentListPresenter } from "../../../../presenters/deploymentListPresenter.server";
export async function loader({ params }: LoaderArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const presenter = new DeploymentListPresenter();
return typedjson(await presenter.data(organizationSlug, projectP));
}
export default function ProjectDeploysPage() {
const { project, needsEnvVars } = useCurrentProject();
const { deployments } = useTypedLoaderData<typeof loader>();
return (
<>
<Title>Deploys</Title>
{needsEnvVars && (
<PanelWarning
message="Deployments are disabled until you add the required environment variables."
className="mb-6"
>
<TertiaryLink to="../settings" className="mr-1">
Set Environment Variables
</TertiaryLink>
</PanelWarning>
)}
{deployments.length === 0 ? (
<Body className="text-slate-500">No deploys yet</Body>
) : (
<List>
{deployments.map((deployment) => (
<DeploymentListItem
pathPrefix="."
key={deployment.id}
deployment={deployment}
repo={project.name}
isCurrentDeployment={
deployment.id === project.currentDeployment?.id
}
/>
))}
</List>
)}
</>
);
}
@@ -0,0 +1,237 @@
import {
ArrowTopRightOnSquareIcon,
ClockIcon,
CloudArrowUpIcon,
CloudIcon,
CubeTransparentIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { Form, useRevalidator } from "@remix-run/react";
import type { ActionArgs, LoaderArgs } from "@remix-run/server-runtime";
import classNames from "classnames";
import { useEffect } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { z } from "zod";
import { List } from "~/components/layout/List";
import { Panel } from "~/components/layout/Panel";
import { PanelWarning } from "~/components/layout/PanelInfo";
import {
PrimaryButton,
SecondaryLink,
TertiaryA,
TertiaryLink,
} from "~/components/primitives/Buttons";
import { Body } from "~/components/primitives/text/Body";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { WorkflowList } from "~/components/workflows/workflowList";
import { ProjectOverviewPresenter } from "~/features/ee/projects/presenters/projectOverviewPresenter.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { useCurrentProject } from "../$projectP";
import { DeploymentListItem } from "../../../components/DeploymentListItem";
import { ManuallyDeployProject } from "../../../services/manuallyDeployProject.server";
import {
deploySummaryGridStyles,
deploySummaryLabelStyles,
deploySummaryValueStyles,
} from "./deploys/$deployId";
export async function loader({ params }: LoaderArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const presenter = new ProjectOverviewPresenter();
return typedjson(await presenter.data(organizationSlug, projectP));
}
export async function action({ params, request }: ActionArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const service = new ManuallyDeployProject();
const deployment = await service.call(projectP);
if (deployment) {
return redirect(
`/orgs/${organizationSlug}/projects/${projectP}/deploys/${deployment.id}`
);
}
return redirectWithErrorMessage(
`/orgs/${organizationSlug}/projects/${projectP}/deploys`,
request,
"Failed to deploy project"
);
}
export default function ProjectOverviewPage() {
const { project, needsEnvVars } = useCurrentProject();
const { workflows, organizationSlug, deployments } =
useTypedLoaderData<typeof loader>();
const events = useEventSource(`/resources/projects/${project.id}`, {
event: "update",
});
const revalidator = useRevalidator();
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
let Icon = ExclamationTriangleIcon;
switch (project.status) {
case "PENDING": {
Icon = ClockIcon;
break;
}
case "PREPARING":
case "BUILDING": {
Icon = CubeTransparentIcon;
break;
}
case "DEPLOYING": {
Icon = CloudArrowUpIcon;
break;
}
case "DEPLOYED": {
Icon = CloudIcon;
break;
}
case "ERROR": {
Icon = ExclamationTriangleIcon;
break;
}
}
return (
<>
<div className="flex items-start justify-between">
<Title>Overview</Title>
<Form
reloadDocument
method="post"
onSubmit={(e) =>
!confirm(
"Are you sure you want to manually deploy this project?"
) && e.preventDefault()
}
>
<PrimaryButton type="submit">
<CloudArrowUpIcon className="-ml-1 h-5 w-5" />
Manual Deploy
</PrimaryButton>
</Form>
</div>
{needsEnvVars && (
<PanelWarning
message="Deployments are disabled until you add the required environment variables."
className="mb-6"
>
<TertiaryLink to="settings" className="mr-1">
Set Environment Variables
</TertiaryLink>
</PanelWarning>
)}
<SubTitle>Repository</SubTitle>
<Panel className="mb-6 px-4 py-5">
<ul className="mb-6 grid grid-cols-3 gap-x-8">
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Name
</Body>
<Body className={deploySummaryValueStyles}>
{project.name}#{project.branch}
</Body>
</li>
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Status
</Body>
<div className="flex items-start gap-2">
<Body className={deploySummaryValueStyles}>
{project.status.charAt(0).toUpperCase() +
project.status.slice(1).toLowerCase()}
</Body>
<div className="h-6 w-6 text-slate-400">
<Icon />
</div>
</div>
</li>
</ul>
<ul className="grid grid-cols-3 gap-x-8">
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
URL
</Body>
<TertiaryA
href={project.url}
target="_blank"
className={classNames(deploySummaryValueStyles, "!text-base")}
>
{project.url}
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
</TertiaryA>
</li>
<li className={deploySummaryGridStyles}>
<Body size="extra-small" className={deploySummaryLabelStyles}>
Latest commit
</Body>
<Body className={deploySummaryValueStyles}>
eb9adfe: "Initial commit" by James Ritchie
</Body>
</li>
</ul>
</Panel>
<div className="relative mb-6 rounded-lg bg-slate-850">
<SubTitle>Workflows</SubTitle>
{workflows.length === 0 ? (
<Body className="text-slate-500">
No workflows have connected in this repo yet
</Body>
) : (
<WorkflowList
workflows={workflows}
currentOrganizationSlug={organizationSlug}
/>
)}
</div>
<div className="relative rounded-lg bg-slate-850">
<div className="mb-2 flex items-center justify-between">
<SubTitle className="mb-0">Latest deploys</SubTitle>
{deployments.length === 0 ? (
<></>
) : (
<SecondaryLink to="deploys">View all</SecondaryLink>
)}
</div>
{deployments.length === 0 ? (
<Body className="text-slate-500">No deploys yet</Body>
) : (
<List>
{deployments.map((deployment) => (
<DeploymentListItem
pathPrefix="deploys"
key={deployment.id}
deployment={deployment}
repo={project.name}
isCurrentDeployment={
deployment.id === project.currentDeployment?.id
}
/>
))}
</List>
)}
</div>
</>
);
}
@@ -0,0 +1,121 @@
import { useRevalidator } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { z } from "zod";
import { PanelWarning } from "~/components/layout/PanelInfo";
import { TertiaryLink } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { Header1, Header4 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { useCurrentProject } from "../$projectP";
import { LogOutput } from "../../../components/LogOutput";
import { ProjectLogsPresenter } from "../../../presenters/projectLogsPresenter.server";
export async function loader({ params }: LoaderArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const presenter = new ProjectLogsPresenter();
return typedjson(await presenter.data(organizationSlug, projectP));
}
export default function ProjectLogsPage() {
const { needsEnvVars } = useCurrentProject();
const { logs, currentDeployment } = useTypedLoaderData<typeof loader>();
if (!currentDeployment) {
return (
<div className="flex h-full flex-col">
<div className="flex items-start justify-between">
<Header1 className="mb-6">Logs</Header1>
</div>
{currentDeployment === null ? (
<Body className="text-slate-500">No logs to display</Body>
) : (
<></>
)}
{needsEnvVars && (
<PanelWarning
message="Deployments are disabled until you add the required environment variables."
className="mb-6"
>
<TertiaryLink to="../settings" className="mr-1">
Set Environment Variables
</TertiaryLink>
</PanelWarning>
)}
</div>
);
}
const events = useEventSource(
`/resources/deploys/${currentDeployment.id}/stream`,
{
event: "update",
}
);
const revalidator = useRevalidator();
useEffect(() => {
if (events !== null) {
revalidator.revalidate();
}
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
}, [events]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex h-full flex-col">
<div className="flex items-baseline justify-between">
<Header1 className="mb-6">Logs</Header1>
<Body size="small" className="text-slate-300">
{currentDeployment.commitMessage} -{" "}
{currentDeployment.commitHash.slice(0, 7)} -{" "}
{currentDeployment.version}
</Body>
</div>
{needsEnvVars && (
<PanelWarning
message="Deployments are disabled until you add the required environment variables."
className="mb-6"
>
<TertiaryLink to="../settings" className="mr-1">
Set Environment Variables
</TertiaryLink>
</PanelWarning>
)}
<div className="mb-2 flex items-center justify-between">
<SubTitle className="mb-0">Deploy logs</SubTitle>
<div className="flex items-center gap-4">
{revalidator.state === "loading" && (
<div className="flex items-center gap-1.5">
<Spinner className="h-4 w-4" />
<Body size="small" className="text-slate-400">
Loading new logs
</Body>
</div>
)}
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500"></span>
<Body size="small" className="text-slate-400">
Live reloading
</Body>
</div>
</div>
</div>
<div className="flex flex-auto overflow-auto rounded-md bg-slate-950 p-4">
<LogOutput logs={logs} />
</div>
</div>
);
}
@@ -0,0 +1,275 @@
import { TrashIcon } from "@heroicons/react/24/outline";
import { Form, useTransition } from "@remix-run/react";
import { ActionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedActionData } from "remix-typedjson";
import { z } from "zod";
import { Panel } from "~/components/layout/Panel";
import { PanelWarning } from "~/components/layout/PanelInfo";
import { DangerButton, PrimaryButton } from "~/components/primitives/Buttons";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Select } from "~/components/primitives/Select";
import { Header1 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import {
setRequestSuccessMessage,
setToastMessageCookie,
} from "~/models/message.server";
import { useCurrentProject } from "../$projectP";
import { DisableProjectService } from "../../../services/disableProject.server";
import { UpdateProjectSettings } from "../../../services/updateProjectSettings.server";
export async function action({ params, request }: ActionArgs) {
const { projectP, organizationSlug } = z
.object({ projectP: z.string(), organizationSlug: z.string() })
.parse(params);
const formPayload = await request.formData();
const action = formPayload.get("action");
if (action === "destroy") {
const service = new DisableProjectService();
const project = await service.call(projectP);
const session = await setRequestSuccessMessage(
request,
`Repository ${project.name} has been removed and will no longer be deployed.`
);
return redirect(`/orgs/${organizationSlug}/projects`, {
headers: await setToastMessageCookie(session),
});
}
const formEntries = Object.fromEntries(formPayload);
// formEntries has the following shape:
// {
// "envVars[GITHUB_REPOSITORY]": "triggerdotdev/trigger.dev",
// "envVars[OTHER_ENV_VAR]": "bar",
// "autoDeploy": "yes",
// "buildCommand": "npm start"
// }
// So we need to transform it into:
// {
// envVars: {
// GITHUB_REPOSITORY: "triggerdotdev/trigger.dev",
// OTHER_ENV_VAR: "bar"
// },
// autoDeploy: "yes",
// buildCommand: "npm start"
// }
const envVars = Object.entries(formEntries)
.filter(([key]) => key.startsWith("envVars["))
.reduce((acc, [key, value]) => {
const envVarKey = key.replace("envVars[", "").replace("]", "");
return { ...acc, [envVarKey]: value };
}, {}) as Record<string, string>;
const service = new UpdateProjectSettings();
console.log(formEntries, envVars);
const validation = service.validate(formEntries, envVars);
switch (validation.type) {
case "payloadError": {
return typedjson(
{ type: "validationError" as const, errors: validation.errors },
{ status: 422 }
);
}
case "success": {
const isDeploying = await service.call(projectP, validation.data);
const session = await setRequestSuccessMessage(
request,
isDeploying
? "Settings updated. Deploying your repo now..."
: "Settings updated. They'll take effect on the next deployment."
);
if (isDeploying) {
return redirect(`/orgs/${organizationSlug}/projects/${projectP}`, {
headers: await setToastMessageCookie(session),
});
}
return typedjson(
{ type: "success" as const },
{ status: 200, headers: await setToastMessageCookie(session) }
);
}
}
}
export default function ProjectSettingsPage() {
const { project, needsEnvVars, envVars } = useCurrentProject();
const actionData = useTypedActionData<typeof action>();
const transition = useTransition();
const isSubmittingOrLoading =
(transition.state === "submitting" &&
transition.type === "actionSubmission" &&
transition.submission.method === "POST") ||
(transition.state === "loading" && transition.type === "actionRedirect");
return (
<div className="flex h-full flex-col">
<div className="flex items-start justify-between">
<Header1 className="mb-6">Settings</Header1>
<Form
method="delete"
reloadDocument
onSubmit={(e) =>
!confirm(
"Are you sure you want to remove this repository? This action cannot be undone. Any running deployments will be stopped."
) && e.preventDefault()
}
>
<DangerButton name="action" value="destroy" type="submit">
<TrashIcon className="-ml-1.5 h-5 w-5" />
Remove repository
</DangerButton>
</Form>
</div>
{needsEnvVars && (
<PanelWarning
message="Deployments are disabled until you add the required environment variables."
className="mb-6"
></PanelWarning>
)}
<SubTitle>Build and deploy</SubTitle>
<Panel className="px-4 py-4">
<Form method="post">
{envVars.length > 0 && (
<>
<SubTitle>Environment variables</SubTitle>
<div className="mb-3 grid grid-cols-1 gap-4">
{envVars.map((envVar) => (
<InputGroup key={envVar.key}>
<Label htmlFor={envVar.key}>{envVar.key}</Label>
<Input
id={envVar.key}
name={`envVars[${envVar.key}]`}
spellCheck={false}
placeholder="Enter a value"
className=""
disabled={isSubmittingOrLoading}
defaultValue={envVar.value}
/>
{!isSubmittingOrLoading &&
actionData?.type === "validationError" && (
<FormError
errors={actionData.errors}
path={[envVar.key]}
/>
)}
</InputGroup>
))}
</div>
</>
)}
<div className="mb-3 grid grid-cols-2 items-start gap-4">
<InputGroup>
<Label htmlFor="autoDeploy">Auto deploy</Label>
<Select
disabled={isSubmittingOrLoading}
name="autoDeploy"
required
defaultValue={project.autoDeploy ? "yes" : "no"}
>
<option value="yes">
Yes, automatically deploy on every push
</option>
<option value="no">No, I'll handle my deploys manually</option>
</Select>
</InputGroup>
<InputGroup>
<Label htmlFor="branch">Repo branch</Label>
<Input
id="branch"
name="branch"
spellCheck={false}
placeholder="main"
className=""
disabled={isSubmittingOrLoading}
defaultValue={project.branch}
/>
{!isSubmittingOrLoading &&
actionData?.type === "validationError" && (
<FormError errors={actionData.errors} path={["branch"]} />
)}
</InputGroup>
</div>
<div className="mb-3 grid grid-cols-2 items-start gap-4">
<InputGroup>
<Label htmlFor="buildCommand">Build command</Label>
<Input
id="buildCommand"
name="buildCommand"
spellCheck={false}
placeholder="npm install"
className=""
disabled={isSubmittingOrLoading}
defaultValue={project.buildCommand}
/>
{!isSubmittingOrLoading &&
actionData?.type === "validationError" && (
<FormError
errors={actionData.errors}
path={["buildCommand"]}
/>
)}
</InputGroup>
<InputGroup>
<Label htmlFor="startCommand">Start command</Label>
<Input
id="startCommand"
name="startCommand"
spellCheck={false}
placeholder="npm install"
className=""
disabled={isSubmittingOrLoading}
defaultValue={project.startCommand}
/>
{!isSubmittingOrLoading &&
actionData?.type === "validationError" && (
<FormError
errors={actionData.errors}
path={["startCommand"]}
/>
)}
</InputGroup>
</div>
<div className="flex justify-end">
{isSubmittingOrLoading ? (
<PrimaryButton disabled>Saving...</PrimaryButton>
) : (
<PrimaryButton type="submit" name="action" value="save">
Save
</PrimaryButton>
)}
</div>
</Form>
</Panel>
</div>
);
}
@@ -0,0 +1,31 @@
import { CloudIcon } from "@heroicons/react/24/outline";
import { AppBody, AppLayoutTwoCol } from "~/components/layout/AppLayout";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import { OrganizationsSideMenu } from "~/components/navigation/SideMenu";
import { Body } from "~/components/primitives/text/Body";
import { Header1 } from "~/components/primitives/text/Headers";
export default function ComingSoonPage() {
return (
<AppLayoutTwoCol>
<OrganizationsSideMenu />
<AppBody>
<Header context="projects" />
<Container className="h-full">
<main className="-mt-20 flex h-full w-full items-center justify-center">
<div className="flex max-w-xl flex-col items-center gap-y-3.5 rounded-md border border-slate-800 bg-slate-800 p-10 text-center shadow-md">
<CloudIcon className="h-10 w-10 text-indigo-500" />
<Header1 size="large">Repositories are coming soon</Header1>
<Body className="text-slate-400">
We're working hard to bring you Repositories. You'll be able to
add workflows to a repo that will be deployed to the Trigger.dev
Cloud.
</Body>
</div>
</main>
</Container>
</AppBody>
</AppLayoutTwoCol>
);
}
@@ -0,0 +1,189 @@
import { CheckIcon } from "@heroicons/react/20/solid";
import {
ArrowTopRightOnSquareIcon,
ChevronRightIcon,
ClockIcon,
CloudArrowUpIcon,
CloudIcon,
CubeTransparentIcon,
ExclamationTriangleIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
import { Link } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { IntlDate } from "~/components/IntlDate";
import { AppBody, AppLayoutTwoCol } from "~/components/layout/AppLayout";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import { List } from "~/components/layout/List";
import { OrganizationsSideMenu } from "~/components/navigation/SideMenu";
import { PrimaryLink, SecondaryA } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { Header2 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { requireUser } from "~/services/session.server";
import {
ProjectListItem,
ProjectListPresenter,
} from "../../presenters/projectListPresenter.server";
export async function loader({ params, request }: LoaderArgs) {
const { organizationSlug } = z
.object({ organizationSlug: z.string() })
.parse(params);
const user = await requireUser(request);
if (!user.featureCloud) {
const url = new URL(request.url);
if (!url.pathname.endsWith("/coming-soon")) {
return redirect(`/orgs/${organizationSlug}/projects/coming-soon`, {
status: 302,
});
}
}
const presenter = new ProjectListPresenter();
return typedjson(await presenter.data(organizationSlug));
}
export default function ProjectDeploysPage() {
const { projects } = useTypedLoaderData<typeof loader>();
return (
<AppLayoutTwoCol>
<OrganizationsSideMenu />
<AppBody>
<Header context="projects" />
<Container>
{projects.length === 0 ? (
<>
<Title>Repositories</Title>
<div className="mb-2 flex flex-col">
<SubTitle className="mb-2">
Add a GitHub repository to get started
</SubTitle>
<div className="flex gap-2">
<PrimaryLink to="../projects/new">
<PlusIcon className="-ml-1 h-4 w-4" />
Add Repo
</PrimaryLink>
<SecondaryA
href="https://docs.trigger.dev"
target="_blank"
rel="noreferrer"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
<span>Documentation</span>
</SecondaryA>
</div>
</div>
</>
) : (
<>
<div className="flex items-start justify-between">
<Title>Repositories</Title>
<PrimaryLink to="../projects/new">
<PlusIcon className="-ml-1 h-4 w-4" />
Add Repo
</PrimaryLink>
</div>
<div className="mb-2 flex items-center justify-between">
<SubTitle className="-mb-1">
{projects.length} connected repo
{projects.length > 1 ? "s" : ""}
</SubTitle>
</div>
</>
)}
<List>
{projects.map((project) => (
<li key={project.id}>
<ProjectListItemView project={project} />
</li>
))}
</List>
</Container>
</AppBody>
</AppLayoutTwoCol>
);
}
export function ProjectListItemView({ project }: { project: ProjectListItem }) {
let Icon = ExclamationTriangleIcon;
switch (project.status) {
case "PENDING": {
Icon = ClockIcon;
break;
}
case "BUILDING": {
Icon = CubeTransparentIcon;
break;
}
case "DEPLOYING": {
Icon = CloudArrowUpIcon;
break;
}
case "DEPLOYED": {
Icon = CloudIcon;
break;
}
case "ERROR": {
Icon = ExclamationTriangleIcon;
break;
}
}
return (
<Link to={project.id} className="block transition hover:!bg-slate-850/40">
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
<div className="flex flex-1 items-center justify-between">
<div className="relative flex items-center">
<div className="mr-4 flex h-20 w-20 flex-shrink-0 items-center justify-center rounded-md bg-slate-850 p-3">
<Icon className="h-12 w-12 text-slate-500" />
</div>
<div className="flex flex-col gap-2">
<Header2 size="regular" className="truncate text-slate-200">
{project.name}
</Header2>
<Body className="text-slate-400">#{project.branch}</Body>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
{project.status === "DEPLOYED" ? (
<CheckIcon className="h-4 w-4 text-green-500" />
) : project.status === "ERROR" ? (
<ExclamationTriangleIcon className="h-4 w-4 text-rose-500" />
) : project.status === "PENDING" ||
project.status === "BUILDING" ||
project.status === "DEPLOYING" ? (
<Spinner className="h-4 w-4" />
) : null}
<Body className="text-slate-300">
{project.status.charAt(0).toUpperCase() +
project.status.slice(1).toLowerCase()}
</Body>
</div>
<Body size="small" className="text-slate-400">
<IntlDate date={project.createdAt} timeZone="UTC" />
</Body>
</div>
<ChevronRightIcon
className="ml-5 h-5 w-5 shrink-0 text-slate-400"
aria-hidden="true"
/>
</div>
</div>
</div>
</Link>
);
}
@@ -0,0 +1,299 @@
import { InformationCircleIcon, StarIcon } from "@heroicons/react/20/solid";
import {
ArrowTopRightOnSquareIcon,
CloudArrowUpIcon,
LockClosedIcon,
LockOpenIcon,
} from "@heroicons/react/24/outline";
import { Await, Form, useLoaderData, useTransition } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { defer } from "@remix-run/server-runtime";
import { Fragment, Suspense } from "react";
import { redirect, typedjson, useTypedActionData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { AppBody, AppLayoutTwoCol } from "~/components/layout/AppLayout";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import { List } from "~/components/layout/List";
import { Panel } from "~/components/layout/Panel";
import { PanelWarning } from "~/components/layout/PanelWarning";
import { OrganizationsSideMenu } from "~/components/navigation/SideMenu";
import {
PrimaryButton,
PrimaryLink,
SecondaryLink,
TertiaryLink,
} from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { Body } from "~/components/primitives/text/Body";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { Tooltip } from "~/components/primitives/Tooltip";
import { NewProjectPresenter } from "~/features/ee/projects/presenters/newProjectPresenter.server";
import { CreateProjectService } from "~/features/ee/projects/services/createProject.server";
import { useCurrentOrganization } from "~/hooks/useOrganizations";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
const userId = await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const presenter = new NewProjectPresenter();
return defer(await presenter.data(userId, params.organizationSlug));
}
export async function action({ request, params }: LoaderArgs) {
const userId = await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const payload = Object.fromEntries(await request.formData());
const service = new CreateProjectService();
const validation = await service.validate(payload);
switch (validation.type) {
case "payloadError":
case "serviceDefinitionError": {
return typedjson(validation, { status: 422 });
}
case "success": {
const result = await service.call(
userId,
params.organizationSlug,
validation.data,
validation.serviceDefinition
);
if (result.type === "serviceError") {
return typedjson(result, { status: 422 });
}
return redirect(
`/orgs/${params.organizationSlug}/projects/${result.project.id}`
);
}
}
}
export default function NewProjectPage() {
const { appAuthorizations, redirectTo, repositories } =
useLoaderData<typeof loader>();
const currentOrganization = useCurrentOrganization();
if (currentOrganization === undefined) {
return <></>;
}
const actionData = useTypedActionData<typeof action>();
const transition = useTransition();
const isSubmittingOrLoading =
(transition.state === "submitting" &&
transition.type === "actionSubmission") ||
(transition.state === "loading" && transition.type === "actionRedirect");
return (
<AppLayoutTwoCol>
<OrganizationsSideMenu />
<AppBody>
<Header context="projects" />
<Container>
<div className="flex items-start justify-between">
<Title>Repositories</Title>
{appAuthorizations.length === 0 ? (
<></>
) : (
<PrimaryLink
to={`/apps/github?redirectTo=${encodeURIComponent(redirectTo)}`}
>
<OctoKitty className="-ml-1 h-5 w-5" />
Connect another GitHub account
</PrimaryLink>
)}
</div>
{appAuthorizations.length === 0 ? (
<>
<ConnectToGithub redirectTo={redirectTo} />
</>
) : (
<>
{(!isSubmittingOrLoading &&
actionData?.type === "serviceDefinitionError") ||
actionData?.type === "serviceError" ? (
<PanelWarning message={actionData.message} className="mb-4" />
) : !isSubmittingOrLoading &&
actionData?.type === "payloadError" ? (
<PanelWarning
message="Something went wrong with your request. Please try again."
className="mb-4"
/>
) : (
<></>
)}
<Suspense
fallback={
<Panel className="mb-6 flex h-10 items-center justify-center gap-2 py-10">
<Spinner />
<Body>Loading repositories</Body>
</Panel>
}
>
<Await
errorElement={<p>Error loading repositories</p>}
resolve={repositories}
>
{(resolvedPromise) => (
<>
{resolvedPromise.map((reposWithAuth) => (
<Fragment key={reposWithAuth.authorization.id}>
<div className="flex w-full items-center justify-between">
<div className="mb-2 flex items-center gap-2">
<OctoKitty className="h-5 w-5" />
<SubTitle className="mb-0">
{reposWithAuth.authorization.accountName}
</SubTitle>
</div>
<TertiaryLink
to={`/apps/github?redirectTo=${encodeURIComponent(
redirectTo
)}&authorizationId=${
reposWithAuth.authorization.id
}`}
reloadDocument
>
Configure{" "}
{reposWithAuth.authorization.accountName}
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-slate-500" />
</TertiaryLink>
</div>
<List className="mb-6">
{reposWithAuth.repositories.map((repo) => (
<li
key={repo.repository.id}
className="flex items-center justify-between gap-2 py-2.5 pl-4 pr-2.5 first:rounded-t-md"
>
<div className="flex items-center gap-2.5">
{repo.repository.private ? (
<LockClosedIcon className="h-4 w-4 text-slate-400" />
) : (
<LockOpenIcon className="h-4 w-4 text-slate-400" />
)}
<a
className="group flex items-center gap-2 text-slate-300 transition hover:text-slate-100"
href={repo.repository.html_url}
target="_blank"
rel="noreferrer"
>
{repo.repository.full_name}#
{repo.repository.default_branch}
<ArrowTopRightOnSquareIcon className="h-4 w-4 text-slate-400 opacity-0 transition group-hover:opacity-100" />
</a>
</div>
{repo.projectId ? (
<div className="flex items-center gap-6">
<SecondaryLink to={`../${repo.projectId}`}>
View Project
</SecondaryLink>
</div>
) : (
<div className="flex items-center gap-6">
{repo.status === "relevant" ? (
<Tooltip text="This repo is configured for deploying a workflow">
<div className="flex items-center gap-1 text-slate-400">
<StarIcon className="h-4 w-4 text-yellow-500" />
<Body size="small" className="">
This repo contains a workflow
</Body>
<InformationCircleIcon className="h-4 w-4" />
</div>
</Tooltip>
) : (
<></>
)}
<Form
method="post"
onSubmit={(e) =>
!confirm(
"This will deploy this repository and automatically run your workflows in the live environment. Are you sure?"
) && e.preventDefault()
}
>
<input
type="hidden"
name="repoId"
value={repo.repository.id}
/>
<input
type="hidden"
name="repoName"
value={repo.repository.full_name}
/>
<input
type="hidden"
name="appAuthorizationId"
value={repo.appAuthorizationId}
/>
<PrimaryButton
type="submit"
size="regular"
>
<CloudArrowUpIcon className="-ml-1 h-5 w-5" />
Deploy
</PrimaryButton>
</Form>
</div>
)}
</li>
))}
</List>
</Fragment>
))}
</>
)}
</Await>
</Suspense>
</>
)}
</Container>
</AppBody>
</AppLayoutTwoCol>
);
}
function ConnectToGithub({ redirectTo }: { redirectTo: string }) {
return (
<>
<SubTitle className="flex items-center">
Grant GitHub repo access to get started
</SubTitle>
<Panel className="mb-6 flex h-56 flex-col items-center justify-center gap-6">
<PrimaryLink
size="large"
to={`/apps/github?redirectTo=${encodeURIComponent(redirectTo)}`}
>
<OctoKitty className="mr-1 h-5 w-5" />
Grant access
</PrimaryLink>
<Body size="extra-small" className="flex items-center text-slate-400">
To deploy a new project you need to authorize our GitHub app.{" "}
<a
href="https://docs.trigger.dev/faq#why-do-we-ask-for-github-access"
target="_blank"
rel="noreferrer"
className="ml-1 underline decoration-slate-500 underline-offset-2 transition hover:cursor-pointer hover:text-slate-300"
>
Learn more.
</a>
</Body>
</Panel>
</>
);
}
@@ -0,0 +1,84 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { randomUUID } from "crypto";
import { eventStream } from "remix-utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { requireUserId } from "~/services/session.server";
export async function loader({ request, params }: LoaderArgs) {
await requireUserId(request);
const { id } = z.object({ id: z.string() }).parse(params);
const project = await findProjectForUpdates(id);
if (!project) {
return new Response("Not found", { status: 404 });
}
let lastUpdatedAt: number = project.updatedAt.getTime();
let lastDeploymentId: string | undefined = project.deployments[0]?.id;
let workflowCount = project._count.workflows;
let stopped = false;
return eventStream(request.signal, (send) => {
const pinger = setInterval(() => {
send({ event: "ping", data: new Date().toISOString() });
}, 1000);
const interval = setInterval(() => {
if (stopped) return;
// Get the updatedAt date from the projects database, and send it to the client if it's different from the last one
findProjectForUpdates(id)
.then((project) => {
if (stopped) return;
if (project) {
if (lastUpdatedAt !== project.updatedAt.getTime()) {
send({ event: "update", data: project.updatedAt.toISOString() });
} else if (lastDeploymentId !== project.deployments[0]?.id) {
send({ event: "update", data: randomUUID() });
} else if (workflowCount !== project._count.workflows) {
send({ event: "update", data: randomUUID() });
}
workflowCount = project._count.workflows;
lastDeploymentId = project.deployments[0]?.id;
lastUpdatedAt = project.updatedAt.getTime();
}
})
.catch((error) => {
stopped = true;
});
}, 348);
return function clear() {
stopped = true;
clearInterval(pinger);
clearInterval(interval);
};
});
}
function findProjectForUpdates(id: string) {
return prisma.repositoryProject.findUnique({
where: {
id,
},
select: {
updatedAt: true,
deployments: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
_count: {
select: {
workflows: true,
},
},
},
});
}
@@ -0,0 +1,69 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { StartDeployment } from "./startDeployment.server";
const PayloadSchema = z.object({
buildId: z.string(),
imageId: z.string(),
});
export class BuildComplete {
#prismaClient: PrismaClient;
#startDeployment = new StartDeployment();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public validate(payload: unknown) {
return PayloadSchema.safeParse(payload);
}
public async call(payload: z.infer<typeof PayloadSchema>) {
console.log(`Build complete: ${payload.buildId} -> ${payload.imageId}`);
const deployment = await this.#updateDeployment(
payload.buildId,
payload.imageId
);
if (!deployment) {
return true;
}
// Only continue if this deployment is building or pending
if (deployment.status !== "BUILDING") {
return true;
}
return this.#startDeployment.call({
deployment,
project: deployment.project,
environment: deployment.environment,
});
}
async #updateDeployment(buildId: string, imageId: string) {
try {
return await this.#prismaClient.projectDeployment.update({
where: {
buildId,
},
data: {
imageId,
},
include: {
environment: true,
project: {
include: {
currentDeployment: true,
},
},
},
});
} catch (error) {
return;
}
}
}
@@ -0,0 +1,42 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
const PayloadSchema = z.object({
buildId: z.string(),
});
export class BuildFailed {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public validate(payload: unknown) {
return PayloadSchema.safeParse(payload);
}
public async call(payload: z.infer<typeof PayloadSchema>) {
console.log(`Build failed: ${payload.buildId}`);
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
buildId: payload.buildId,
},
});
if (!deployment) {
return true;
}
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "ERROR",
},
});
}
}
@@ -0,0 +1,47 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
export class CancelProjectDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return true;
}
if (deployment.status !== "PENDING") {
return true;
}
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "CANCELLED",
},
});
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.projectId,
},
data: {
updatedAt: new Date(),
},
});
}
}
@@ -0,0 +1,101 @@
import { CakeworkApiError } from "@cakework/client/dist";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { cakework } from "../cakework.server";
export class CleanupDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return true;
}
if (deployment.status !== "DEPLOYED") {
return true;
}
if (deployment.project.currentDeploymentId === id) {
return true;
}
if (!deployment.vmIdentifier) {
return true;
}
try {
console.log(
`Stopping VM: ${deployment.vmIdentifier} for deployment ${id}`
);
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "STOPPING",
stoppedAt: new Date(),
},
});
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.projectId,
},
data: {
updatedAt: new Date(),
},
});
await cakework.stopVm(deployment.vmIdentifier);
console.log(
`Stopped VM: ${deployment.vmIdentifier} for deployment ${id}`
);
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "STOPPED",
stoppedAt: new Date(),
},
});
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.projectId,
},
data: {
updatedAt: new Date(),
},
});
return true;
} catch (error) {
if (error instanceof CakeworkApiError) {
console.log(
`Failed to stop VM: ${deployment.vmIdentifier} for deployment ${id}: ${error.statusCode} ${error.message}`
);
return false;
}
throw error;
}
}
}
@@ -0,0 +1,79 @@
import { prisma, PrismaClient } from "~/db.server";
import { taskQueue } from "~/services/messageBroker.server";
export class CleanupProject {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(projectId: string) {
// Deployments with a vmIdentifier and NOT STOPPED should be stopped
const deployments = await this.#prismaClient.projectDeployment.findMany({
where: {
projectId,
vmIdentifier: {
not: null,
},
status: {
notIn: ["STOPPED"],
},
},
});
for (const deployment of deployments) {
if (deployment.vmIdentifier) {
await taskQueue.publish("STOP_VM", {
id: deployment.vmIdentifier,
});
}
}
// PENDING deployments should be cancelled
// BUILDING deployments should be stopped
// DEPLOYING deployments should be stopped
// DEPLOYED deployments should be stopped
// STOPPING deployments should be stopped
// ERROR deployments should be ignored
// STOPPED deployments should be ignored
// CANCELLED deployments should be ignored
await this.#prismaClient.projectDeployment.updateMany({
where: {
projectId,
status: {
in: ["BUILDING", "DEPLOYING", "DEPLOYED", "STOPPING"],
},
},
data: {
status: "STOPPED",
stoppedAt: new Date(),
},
});
await this.#prismaClient.projectDeployment.updateMany({
where: {
projectId,
status: {
in: ["PENDING"],
},
},
data: {
status: "CANCELLED",
},
});
// Delete all deployment logs for this project that are older than 1 day
await this.#prismaClient.deploymentLog.deleteMany({
where: {
deployment: {
projectId,
},
createdAt: {
lt: new Date(Date.now() - 1000 * 60 * 60 * 24),
},
},
});
}
}
@@ -0,0 +1,154 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { BlueprintService } from "~/features/ee/projects/models/repositoryProject.server";
import {
repositoryProjectReadyToDeploy,
serviceDefinitionFromRepository,
} from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
const FormSchema = z.object({
repoId: z.string(),
repoName: z.string(),
appAuthorizationId: z.string(),
});
export type CreateProjectValidationResult =
| {
type: "payloadError";
errors: z.ZodIssue[];
}
| {
type: "serviceDefinitionError";
message: string;
}
| {
type: "success";
data: z.infer<typeof FormSchema>;
serviceDefinition: BlueprintService;
};
export class CreateProjectService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
userId: string,
organizationSlug: string,
data: z.infer<typeof FormSchema>,
serviceDefinition: BlueprintService
) {
try {
const project = await this.#prismaClient.repositoryProject.create({
data: {
name: data.repoName,
url: `https://github.com/${data.repoName}`,
authorization: {
connect: {
id: data.appAuthorizationId,
},
},
branch: "main",
organization: {
connect: {
slug: organizationSlug,
},
},
buildCommand: serviceDefinition.buildCommand ?? "npm run build",
startCommand: serviceDefinition.startCommand ?? "npm run start",
envVars: serviceDefinition.envVars,
},
});
if (repositoryProjectReadyToDeploy(project)) {
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "PREPARING",
},
});
await taskQueue.publish("START_INITIAL_PROJECT_DEPLOYMENT", {
id: project.id,
});
}
return { type: "success" as const, project };
} catch (error) {
// Handle Prisma unique constraint error (name must be unique)
if (
typeof error === "object" &&
error &&
"code" in error &&
error.code === "P2002"
) {
return {
type: "serviceError" as const,
message:
"Cannot deploy this repository because it is already being used.",
};
}
throw error;
}
}
public async validate(
payload: unknown
): Promise<CreateProjectValidationResult> {
const payloadValidation = FormSchema.safeParse(payload);
if (!payloadValidation.success) {
return {
type: "payloadError" as const,
errors: payloadValidation.error.issues,
};
}
const serviceDefinition = await serviceDefinitionFromRepository(
payloadValidation.data.appAuthorizationId,
payloadValidation.data.repoName
);
if (!serviceDefinition) {
return {
type: "serviceDefinitionError" as const,
message:
"Could not find a service definition in the selected repository. We only support repositories based on our templates for now.",
};
}
if (!this.#validateServiceMetadata(serviceDefinition)) {
return {
type: "serviceDefinitionError" as const,
message:
"Could not find a service definition in the selected repository. We only support repositories based on our templates for now.",
};
}
return {
type: "success" as const,
data: payloadValidation.data,
serviceDefinition,
};
}
// Make sure the service metadata is valid
// env = node
// type = worker
// envVar with key TRIGGER_API_KEY
#validateServiceMetadata(serviceMetadata: BlueprintService) {
return (
serviceMetadata.env === "node" &&
serviceMetadata.type === "worker" &&
serviceMetadata.envVars.find((envVar) => envVar.key === "TRIGGER_API_KEY")
);
}
}
@@ -0,0 +1,176 @@
import type {
RepositoryProject,
RuntimeEnvironment,
ProjectDeployment,
} from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { cakework } from "../cakework.server";
import { taskQueue } from "~/services/messageBroker.server";
import type { GitHubAppAuthorizationWithValidToken } from "../github/refreshInstallationAccessToken.server";
import type { GitHubCommit } from "../github/githubApp.server";
import { getNextDeploymentVersion } from "../models/repositoryProject.server";
export type CreateProjectDeploymentOptions = {
project: RepositoryProject;
authorization: GitHubAppAuthorizationWithValidToken;
environment: RuntimeEnvironment;
commit: GitHubCommit;
};
export class CreateProjectDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
{
project,
environment,
authorization,
commit,
}: CreateProjectDeploymentOptions,
retryCount = 0
): Promise<ProjectDeployment | undefined> {
const version = await getNextDeploymentVersion(project.id);
const dockerfile = formatFileContents(`
FROM node:18-bullseye-slim
WORKDIR /app
COPY package*.json ./
RUN ${project.buildCommand}
COPY . .
CMD [${project.startCommand
.split(" ")
.map((s) => `"${s}"`)
.join(", ")}]
`);
const dockerIgnore = formatFileContents(`
node_modules
`);
console.log(
`[${version}][attempt=${retryCount + 1}] Building image for ${
project.name
} with token ${authorization.installationAccessToken}`
);
const build = await cakework.buildImageFromGithub({
dockerfile: dockerfile,
dockerignore: dockerIgnore,
token: authorization.installationAccessToken,
repository: project.name,
branch: project.branch,
});
console.log(
`[${version}][attempt=${retryCount + 1}] Build started for ${
project.name
} with id ${build.buildId}`
);
try {
// Create the deployment
// Setting the buildStartAt because even though this is a PENDING deployment,
// we have already started to build it with Cakework (it can still end up not getting deployed if this deployment is cancelled)
const deployment = await this.#prismaClient.projectDeployment.create({
data: {
version,
buildId: build.buildId,
buildStartedAt: new Date(),
project: {
connect: {
id: project.id,
},
},
environment: {
connect: {
id: environment.id,
},
},
status: "PENDING",
branch: project.branch,
commitHash: commit.sha,
commitMessage: commit.commit.message,
committer: getCommitAuthor(commit),
dockerfile,
dockerIgnore,
},
});
await taskQueue.publish("PROJECT_DEPLOYMENT_CREATED", {
id: deployment.id,
});
return deployment;
} catch (error) {
console.error(
`[${version}] Error creating deployment for ${project.name}: ${error}`
);
if (typeof error === "object" && error !== null) {
if ("code" in error && error.code === "P2002") {
if (retryCount > 3) {
return;
}
// If the deployment version already exists, then we should retry
return await this.call(
{
project,
environment,
authorization,
commit,
},
retryCount + 1
);
}
}
throw error;
}
}
}
function getCommitAuthor(commit: GitHubCommit) {
if (commit.commit.author && commit.commit.author.name) {
return commit.commit.author.name;
}
if (commit.committer && commit.committer.login) {
return commit.committer.login;
}
if (commit.author && commit.author.login) {
return commit.author.login;
}
return "Unknown";
}
// Remove newlines at the beginning of the file, and remove any leading whitespace on each line (make sure not to remove any other whitespace)
// For example, the following input:
//
// FROM node:bullseye-slim
// WORKDIR /app
// COPY package*.json ./
// RUN npm install && npm run build
// COPY . .
// CMD ["node", "dist/index.js"]
//
// Would be formatted to:
// FROM node:bullseye-slim
// WORKDIR /app
// COPY package*.json ./
// RUN npm install && npm run build
// COPY . .
// CMD ["node", "dist/index.js"]
function formatFileContents(contents: string) {
return contents
.trimStart()
.split("\n")
.map((line) => line.trimStart())
.join("\n");
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { statusTextForBuilding } from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
export class DeploymentCreated {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return;
}
// If the RepositoryProjec status is:
// - PENDING: Update the project to be building
// - DEPLOYED: Update the project to be building
// - PREPARING: Update the project to be building
// - BUILDING: Do nothing
// - ERROR: Update the project to be building
// - DISABLED: Do nothing
// - DEPLOYING: Do nothing
switch (deployment.project.status) {
case "PENDING":
case "DEPLOYED":
case "PREPARING":
case "ERROR": {
// Set the project as "building"
// will transition to "deploying" when the build is complete (see: buildComplete.server.ts)
console.log(
`Setting project ${deployment.project.id} and deployment ${deployment.id} to BUILDING`
);
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.project.id,
},
data: {
status: "BUILDING",
statusText: statusTextForBuilding(deployment, deployment.project),
},
});
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "BUILDING",
},
});
}
}
// Start polling for logs for this project deployment
await taskQueue.publish("DEPLOYMENT_LOG_POLL", {
id: deployment.id,
count: 0,
});
// We also need to check if there are any other PENDING deployments for this project
// and if so we should cancel them (because we have a newer push)
await this.#prismaClient.projectDeployment.updateMany({
where: {
projectId: deployment.project.id,
status: "PENDING",
id: {
not: deployment.id,
},
},
data: {
status: "CANCELLED",
},
});
}
}
@@ -0,0 +1,37 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { taskQueue } from "~/services/messageBroker.server";
export class DisableProjectService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(projectId: string) {
const project =
await this.#prismaClient.repositoryProject.findUniqueOrThrow({
where: {
id: projectId,
},
});
if (project.status !== "PENDING") {
await taskQueue.publish("CLEANUP_PROJECT", {
id: project.id,
});
}
await this.#prismaClient.repositoryProject.update({
where: {
id: projectId,
},
data: {
status: "DISABLED",
},
});
return project;
}
}
@@ -0,0 +1,52 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import { findProjectById } from "~/features/ee/projects/models/repositoryProject.server";
import { getCommit } from "../github/githubApp.server";
import { refreshInstallationAccessToken } from "../github/refreshInstallationAccessToken.server";
import { CreateProjectDeployment } from "./createProjectDeployment.server";
export class InitialProjectDeployment {
#createProjectDeployment = new CreateProjectDeployment();
public async call(projectId: string) {
const project = await findProjectById(projectId);
if (!project) {
return;
}
// If the project isn't either PENDING or PREPARING, then we return
if (project.status !== "PENDING" && project.status !== "PREPARING") {
return;
}
const environment = project.organization.environments.find(
(environment) => environment.slug === LIVE_ENVIRONMENT
);
if (!environment) {
return;
}
// Retrieve the latest commit from the "main" branch in the repo
const appAuthorization = await refreshInstallationAccessToken(
project.authorizationId
);
const latestCommit = await getCommit(
appAuthorization.installationAccessToken,
project.name,
project.branch
);
console.log(
`Creating deployment for latest commit for ${project.name}: ${latestCommit.sha}`
);
return await this.#createProjectDeployment.call({
project,
environment,
authorization: appAuthorization,
commit: latestCommit,
});
}
}
@@ -0,0 +1,47 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import { findProjectById } from "~/features/ee/projects/models/repositoryProject.server";
import { getCommit } from "../github/githubApp.server";
import { refreshInstallationAccessToken } from "../github/refreshInstallationAccessToken.server";
import { CreateProjectDeployment } from "./createProjectDeployment.server";
export class ManuallyDeployProject {
#createProjectDeployment = new CreateProjectDeployment();
public async call(projectId: string) {
const project = await findProjectById(projectId);
if (!project) {
return;
}
const environment = project.organization.environments.find(
(environment) => environment.slug === LIVE_ENVIRONMENT
);
if (!environment) {
return;
}
// Retrieve the latest commit from the "main" branch in the repo
const appAuthorization = await refreshInstallationAccessToken(
project.authorizationId
);
const latestCommit = await getCommit(
appAuthorization.installationAccessToken,
project.name,
project.branch
);
console.log(
`Creating deployment for latest commit for ${project.name}: ${latestCommit.sha}`
);
return await this.#createProjectDeployment.call({
project,
environment,
authorization: appAuthorization,
commit: latestCommit,
});
}
}
@@ -0,0 +1,351 @@
import type { DeploymentLogPoll, ProjectDeployment } from ".prisma/client";
import { createHash } from "node:crypto";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { taskQueue } from "~/services/messageBroker.server";
import type {
GetLogsOptions,
GetLogsResponse,
LogLine,
} from "../cakework.server";
import { getBuildLogs, getVmLogs } from "../cakework.server";
export class PollDeploymentLogs {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string, count: number) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return true;
}
switch (deployment.status) {
case "STOPPED":
case "ERROR":
case "CANCELLED": {
return true;
}
case "PENDING":
case "BUILDING":
case "DEPLOYING": {
const poll = await this.#pollLogs(deployment, "BUILD", count);
await this.#scheduleNextPoll(deployment, poll, count);
break;
}
case "STOPPING":
case "DEPLOYED": {
const poll = await this.#pollLogs(deployment, "MACHINE", count);
await this.#scheduleNextPoll(deployment, poll, count);
break;
}
default:
const _exhaustiveCheck: never = deployment.status;
return _exhaustiveCheck;
}
}
async #scheduleNextPoll(
deployment: ProjectDeployment,
poll: DeploymentLogPoll,
count: number = 0
) {
if (!poll.nextPollScheduledAt) {
return;
}
await taskQueue.publish(
"DEPLOYMENT_LOG_POLL",
{ id: deployment.id, count: poll.pollNumber },
{},
{ deliverAt: poll.nextPollScheduledAt.getTime() }
);
}
async #calculateNextPollScheduledAt(
deployment: ProjectDeployment,
logType: "BUILD" | "MACHINE"
): Promise<Date | undefined> {
const lastLog = await this.#prismaClient.deploymentLog.findFirst({
where: {
deploymentId: deployment.id,
logType,
},
orderBy: {
createdAt: "desc",
},
});
const latestLogAt = lastLog?.createdAt;
if (!latestLogAt) {
console.log(
`[${logType}] No logs for ${deployment.id} yet. Trying again in 3 seconds.`
);
return new Date(Date.now() + 3000);
}
// and it's less than 10 seconds old, schedule again in 3 seconds
if (latestLogAt.getTime() > Date.now() - 10000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 10 seconds old. Trying again in 3 seconds.`
);
return new Date(Date.now() + 3000);
}
// and it's less than 60 seconds old, schedule again in 5 seconds
if (latestLogAt.getTime() > Date.now() - 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 60 seconds old. Trying again in 5 seconds.`
);
return new Date(Date.now() + 5000);
}
// and it's less than 5 minutes old, schedule again in 10 seconds
if (latestLogAt.getTime() > Date.now() - 5 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 5 minutes old. Trying again in 10 seconds.`
);
return new Date(Date.now() + 10000);
}
// And it's less than 15 minutes old, schedule again in 15 seconds
if (latestLogAt.getTime() > Date.now() - 15 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 15 minutes old. Trying again in 15 seconds.`
);
return new Date(Date.now() + 15000);
}
// And it's less than 30 minutes old, schedule again in 20 seconds
if (latestLogAt.getTime() > Date.now() - 20 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 20 minutes old. Trying again in 20 seconds.`
);
return new Date(Date.now() + 20000);
}
// And it's less than 60 minutes old, schedule again in 30 seconds
if (latestLogAt.getTime() > Date.now() - 60 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 60 minutes old. Trying again in 30 seconds.`
);
return new Date(Date.now() + 30000);
}
// And it's less than 24 hours old, schedule again in 5 minutes
if (latestLogAt.getTime() > Date.now() - 24 * 60 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 24 hours old. Trying again in 5 minutes.`
);
return new Date(Date.now() + 5 * 60 * 1000);
}
// And it's less than 7 days old, schedule again in 30 minutes
if (latestLogAt.getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 7 days old. Trying again in 30 minutes.`
);
return new Date(Date.now() + 30 * 60 * 1000);
}
// If it's less than 30 days old, schedule again in 2 hours
if (latestLogAt.getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000) {
console.log(
`[${logType}] Latest log for ${deployment.id} is less than 30 days old. Trying again in 2 hours.`
);
return new Date(Date.now() + 2 * 60 * 60 * 1000);
}
// Else stop scheduling
console.log(
`[${logType}] Latest log for ${deployment.id} is more than 30 days old. Stopping polling.`
);
}
async #pollLogs(
deployment: ProjectDeployment,
logType: "BUILD" | "MACHINE",
pollNumber: number
) {
const latestPoll = await this.#prismaClient.deploymentLogPoll.findFirst({
where: {
deploymentId: deployment.id,
logType,
},
orderBy: {
createdAt: "desc",
},
});
// or start from 24 hours ago
let fromDate =
latestPoll?.to ??
deployment.buildFinishedAt ??
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
// Add 1 millisecond to fromDate to avoid duplicate logs
fromDate = new Date(fromDate.getTime() + 1);
const toDate = new Date();
console.log(
`[${logType}] Polling logs for ${
deployment.id
}, from ${fromDate.toISOString()} to ${toDate.toISOString()}...`
);
const logs = await this.#gatherLogs(deployment, logType, fromDate, toDate);
const filteredLogs = logs.filter((log) => filterLine(log.message));
// Create a new log for each line
await this.#prismaClient.deploymentLog.createMany({
data: filteredLogs.map((log, index) =>
createLogRecord(deployment.id, index, log, logType)
),
skipDuplicates: true,
});
const latestFilteredLog = filteredLogs[filteredLogs.length - 1];
return await this.#prismaClient.deploymentLogPoll.create({
data: {
deployment: {
connect: {
id: deployment.id,
},
},
logType,
from: fromDate,
to: toDate,
totalLogsCount: logs.length,
filteredLogsCount: filteredLogs.length,
nextPollScheduledAt: await this.#calculateNextPollScheduledAt(
deployment,
logType
),
pollNumber: pollNumber + 1,
},
});
}
async #gatherLogs(
deployment: ProjectDeployment,
logType: "BUILD" | "MACHINE",
fromDate: Date,
toDate: Date,
paginationToken?: string,
logs: LogLine[] = []
): Promise<Array<LogLine>> {
const logsResponse = await this.#getLogs(deployment, logType, {
from: fromDate,
to: toDate,
batch: 100,
pagination: paginationToken,
});
if (logsResponse.pagination) {
return this.#gatherLogs(
deployment,
logType,
fromDate,
toDate,
logsResponse.pagination,
logs.concat(logsResponse.lines)
);
}
return logs.concat(logsResponse.lines);
}
async #getLogs(
deployment: ProjectDeployment,
logType: "BUILD" | "MACHINE",
options: GetLogsOptions = {}
): Promise<GetLogsResponse> {
switch (logType) {
case "BUILD":
return getBuildLogs(deployment.buildId, options);
case "MACHINE":
if (!deployment.vmIdentifier) {
return { lines: [] };
}
return getVmLogs(deployment.vmIdentifier, options);
default:
throw new Error(`Unknown log type: ${logType}`);
}
}
}
function createLogRecord(
deploymentId: string,
logNumber: number,
log: LogLine,
logType: "BUILD" | "MACHINE"
) {
const id = createHashFromArgs(
deploymentId,
String(log.timestamp),
log.message
);
return {
id,
deploymentId,
logNumber,
logType,
log: log.message.trim(),
level: log.level,
createdAt: new Date(log.timestamp),
};
}
function createHashFromArgs(...args: string[]): string {
return createHash("md5").update(args.join("")).digest("hex");
}
// Lines to filter out:
// waiting for machine to listen on 0.0.0.0:8080 (waited 5.626604s so far)"
// "Configuring firecracker"
// failed to connect to machine: gave up after 50 attempts (in 44.532756592s)
// "Starting machine"
// failed to connect to instance after 6 attempts
// Failed to proxy HTTP request (error: could not find an instance to route to). Retrying in 1000 ms (attempt 10)
// machines API returned an error
function filterLine(message: string) {
const regexes = [
/waiting for machine to listen on/,
/Configuring firecracker/,
/failed to connect to machine/,
/Starting machine/,
/failed to connect to instance/,
/Failed to proxy HTTP request/,
/machines API returned an error/,
];
return !regexes.some((regex) => regex.test(message));
}
@@ -0,0 +1,61 @@
import { LIVE_ENVIRONMENT } from "~/consts";
import { findProjectByRepo } from "~/features/ee/projects/models/repositoryProject.server";
import { getCommit } from "../github/githubApp.server";
import { refreshInstallationAccessToken } from "../github/refreshInstallationAccessToken.server";
import { CreateProjectDeployment } from "./createProjectDeployment.server";
export class ReceiveRepositoryPush {
#createProjectDeployment = new CreateProjectDeployment();
public async call(data: {
branch: string;
commitSha: string;
repository: string;
}) {
console.log(
`Received push for ${data.repository} on ${data.branch}: ${data.commitSha}`
);
const project = await findProjectByRepo(data.repository);
if (!project) {
return;
}
if (project.branch !== data.branch) {
return;
}
if (!project.autoDeploy) {
return;
}
const environment = project.organization.environments.find(
(environment) => environment.slug === LIVE_ENVIRONMENT
);
if (!environment) {
return;
}
// Retrieve the latest commit from the "main" branch in the repo
const appAuthorization = await refreshInstallationAccessToken(
project.authorizationId
);
const commit = await getCommit(
appAuthorization.installationAccessToken,
project.name,
data.commitSha
);
console.log(`Received commit for ${project.name}: ${commit.sha}`);
await this.#createProjectDeployment.call({
project,
environment,
authorization: appAuthorization,
commit,
});
}
}
@@ -0,0 +1,150 @@
import type {
ProjectDeployment,
RepositoryProject,
RuntimeEnvironment,
} from ".prisma/client";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import {
buildEnvVars,
statusTextForDeployed,
statusTextForDeploying,
} from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
import { cakework } from "../cakework.server";
export type StartDeploymentOptions = {
deployment: ProjectDeployment;
project: RepositoryProject;
environment: RuntimeEnvironment;
};
export class StartDeployment {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
deployment,
project,
environment,
}: StartDeploymentOptions) {
if (!deployment.imageId) {
return true;
}
console.log(`Starting deployment ${deployment.id} for ${project.id}`);
// Update the project and deployment status to deploying
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "DEPLOYING",
statusText: statusTextForDeploying(deployment, project),
},
});
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "DEPLOYING",
},
});
const vmStart = performance.now();
const envVars = buildEnvVars(deployment, project, environment);
console.log(
`Starting VM for ${deployment.id} with envVars: ${JSON.stringify(
envVars
)}`
);
try {
const vm = await cakework.startVm({
imageId: deployment.imageId,
cpu: 1,
memory: 256,
envVars,
});
const vmEnd = performance.now();
console.log(
`Started VM for ${deployment.id} in ${(vmEnd - vmStart).toFixed(2)}ms`
);
// Update the deployment with the VM
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
vmIdentifier: vm.id,
status: "DEPLOYED",
buildFinishedAt: new Date(),
},
});
// Update the project
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "DEPLOYED",
currentDeploymentId: deployment.id,
statusText: statusTextForDeployed(deployment, project),
},
});
// If there are any pending deployments, we can start them now
await taskQueue.publish("DEPLOYMENT_DEPLOYED", {
id: deployment.id,
projectId: project.id,
});
// Make sure to stop the previous deployment
if (project.currentDeploymentId) {
await taskQueue.publish("CLEANUP_DEPLOYMENT", {
id: project.currentDeploymentId,
});
}
} catch (error) {
// Update the deployment to be errored
await this.#prismaClient.projectDeployment.update({
where: {
id: deployment.id,
},
data: {
status: "ERROR",
error: JSON.parse(JSON.stringify(error)),
},
});
// Update the project to be errored
await this.#prismaClient.repositoryProject.update({
where: {
id: project.id,
},
data: {
status: "ERROR",
statusText: `Error building project: ${
error instanceof Error ? error.message : error
}`,
},
});
return false;
}
return true;
}
}
@@ -0,0 +1,55 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { StartDeployment } from "./startDeployment.server";
export class StartPendingDeployment {
#prismaClient: PrismaClient;
#startDeployment = new StartDeployment();
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const project = await this.#prismaClient.repositoryProject.findUnique({
where: {
id,
},
});
if (!project) {
return;
}
if (project.status !== "DEPLOYED") {
return;
}
// Find latest pending deployment with an imageIdentifier
const deployment = await this.#prismaClient.projectDeployment.findFirst({
where: {
projectId: project.id,
status: "PENDING",
imageId: {
not: null,
},
},
orderBy: {
createdAt: "desc",
},
include: {
environment: true,
},
});
if (!deployment) {
return;
}
return await this.#startDeployment.call({
deployment,
project,
environment: deployment.environment,
});
}
}
@@ -0,0 +1,96 @@
import { CakeworkApiError } from "@cakework/client/dist";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { cakework } from "../cakework.server";
export class StopProjectDeployment {
#prismaClient: PrismaClient;
#stoppableStatuses = ["BUILDING", "DEPLOYED"];
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async call(id: string) {
const deployment = await this.#prismaClient.projectDeployment.findUnique({
where: {
id,
},
include: {
project: true,
},
});
if (!deployment) {
return true;
}
if (!this.#stoppableStatuses.includes(deployment.status)) {
return true;
}
try {
if (deployment.vmIdentifier) {
console.log(
`Stopping VM: ${deployment.vmIdentifier} for deployment ${id}`
);
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "STOPPING",
stoppedAt: new Date(),
},
});
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.projectId,
},
data: {
updatedAt: new Date(),
},
});
await cakework.stopVm(deployment.vmIdentifier);
console.log(
`Stopped VM: ${deployment.vmIdentifier} for deployment ${id}`
);
}
await this.#prismaClient.projectDeployment.update({
where: {
id,
},
data: {
status: "STOPPED",
stoppedAt: new Date(),
},
});
await this.#prismaClient.repositoryProject.update({
where: {
id: deployment.projectId,
},
data: {
updatedAt: new Date(),
},
});
return true;
} catch (error) {
if (error instanceof CakeworkApiError) {
console.log(
`Failed to stop VM: ${deployment.vmIdentifier} for deployment ${id}: ${error.statusCode} ${error.message}`
);
return false;
}
throw error;
}
}
}
@@ -0,0 +1,138 @@
import { z } from "zod";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import {
parseEnvVars,
repositoryProjectReadyToDeploy,
} from "~/features/ee/projects/models/repositoryProject.server";
import { taskQueue } from "~/services/messageBroker.server";
const ValueSchema = z
.string()
.min(1, { message: "Environment variables cannot be blank" })
.max(1024, {
message: "Environment variables cannot be longer than 1024 characters",
})
.trim();
const EnvVarSchema = z.record(ValueSchema);
const PayloadSchema = z.object({
buildCommand: z
.string()
.min(1, { message: "Build Command cannot be blank" })
.max(1024)
.trim(),
startCommand: z
.string()
.min(1, { message: "Start Command cannot be blank" })
.max(1024),
branch: z.string().min(1, { message: "Branch cannot be blank" }).max(256),
autoDeploy: z.enum(["yes", "no"]),
});
const FormSchema = PayloadSchema.extend({
envVars: EnvVarSchema,
});
export type UpdateEnvironmentVariablesValidationResult =
| {
type: "payloadError";
errors: z.ZodIssue[];
}
| {
type: "success";
data: z.infer<typeof FormSchema>;
};
export class UpdateProjectSettings {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(projectId: string, data: z.infer<typeof FormSchema>) {
const project =
await this.#prismaClient.repositoryProject.findUniqueOrThrow({
where: {
id: projectId,
},
});
const envVars = parseEnvVars(project);
const newEnvVars = envVars.map((envVar) => {
if (data.envVars[envVar.key]) {
return {
...envVar,
value: data.envVars[envVar.key],
};
}
return envVar;
});
const udpatedProject = await this.#prismaClient.repositoryProject.update({
where: {
id: projectId,
},
data: {
envVars: newEnvVars,
buildCommand: data.buildCommand,
startCommand: data.startCommand,
autoDeploy: data.autoDeploy === "yes",
branch: data.branch,
},
});
let shouldDeploy = false;
if (repositoryProjectReadyToDeploy(udpatedProject)) {
await this.#prismaClient.repositoryProject.update({
where: {
id: projectId,
},
data: {
status: "PREPARING",
},
});
await taskQueue.publish("START_INITIAL_PROJECT_DEPLOYMENT", {
id: projectId,
});
shouldDeploy = true;
}
return shouldDeploy;
}
public validate(
payload: unknown,
envVars: Record<string, string>
): UpdateEnvironmentVariablesValidationResult {
const envVarsValidation = EnvVarSchema.safeParse(envVars);
if (!envVarsValidation.success) {
return {
type: "payloadError" as const,
errors: envVarsValidation.error.issues,
};
}
const payloadValidation = PayloadSchema.safeParse(payload);
if (!payloadValidation.success) {
return {
type: "payloadError" as const,
errors: payloadValidation.error.issues,
};
}
return {
type: "success" as const,
data: { ...payloadValidation.data, envVars: envVarsValidation.data },
};
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import type { APIConnection } from ".prisma/client";
import type { ServiceMetadata } from "@trigger.dev/integration-sdk";
import type { UseDataFunctionReturn } from "remix-typedjson/dist/remix";
import type { loader } from "~/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug";
import type { loader } from "~/routes/__app/orgs/$organizationSlug/__org/workflows/$workflowSlug";
import { hydrateObject, useMatchesData } from "~/utils";
export type ConnectionSlot = {
@@ -13,7 +13,7 @@ export type ConnectionSlot = {
export function useConnectionSlots() {
const routeMatch = useMatchesData(
"routes/__app/orgs/$organizationSlug/workflows/$workflowSlug"
"routes/__app/orgs/$organizationSlug/__org/workflows/$workflowSlug"
);
if (!routeMatch || !routeMatch.data.connectionSlots) {
@@ -0,0 +1,16 @@
import { useMatches } from "@remix-run/react";
export function useIsOrgChildPage() {
const matchesData = useMatches();
return matchesData.some((matchData) => {
return (
matchData.id.startsWith(
"routes/__app/orgs/$organizationSlug/__org/workflows/$workflowSlug"
) ||
matchData.id.startsWith(
"routes/__app/orgs/$organizationSlug/__org/projects/$projectP"
)
);
});
}
+1
View File
@@ -25,6 +25,7 @@ export const usePostHog = (apiKey?: string, logging = false): void => {
postHogInitialized.current = true;
posthog.init(apiKey, {
api_host: "https://app.posthog.com",
opt_in_site_apps: true,
loaded: function (posthog) {
if (logging) console.log("posthog.loaded", apiKey);
if (user !== undefined) {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { UseDataFunctionReturn } from "remix-typedjson/dist/remix";
import type { loader as orgLoader } from "~/routes/__app/orgs/$organizationSlug";
import type { loader as workflowLoader } from "~/routes/__app/orgs/$organizationSlug/workflows/$workflowSlug";
import type { loader as workflowLoader } from "~/routes/__app/orgs/$organizationSlug/__org/workflows/$workflowSlug";
import { hydrateObject, useMatchesData } from "~/utils";
export function useWorkflows() {
@@ -19,7 +19,7 @@ export type OrgWorkflow = NonNullable<ReturnType<typeof useWorkflows>>[number];
export function useCurrentWorkflow() {
const routeMatch = useMatchesData(
"routes/__app/orgs/$organizationSlug/workflows/$workflowSlug"
"routes/__app/orgs/$organizationSlug/__org/workflows/$workflowSlug"
);
if (!routeMatch || !routeMatch.data.workflow) {
+30
View File
@@ -26,6 +26,36 @@ export function setErrorMessage(session: Session, message: string) {
session.flash("toastMessage", { message, type: "error" } as ToastMessage);
}
export async function setRequestErrorMessage(
request: Request,
message: string
) {
const session = await getSession(request.headers.get("cookie"));
setErrorMessage(session, message);
return session;
}
export async function setRequestSuccessMessage(
request: Request,
message: string
) {
const session = await getSession(request.headers.get("cookie"));
setSuccessMessage(session, message);
return session;
}
export async function setToastMessageCookie(session: Session) {
return {
"Set-Cookie": await commitSession(session, {
expires: new Date(Date.now() + ONE_YEAR),
}),
};
}
export async function jsonWithSuccessMessage(
data: any,
request: Request,
@@ -18,6 +18,30 @@ export async function findEnvironmentByApiKey(apiKey: string) {
return environment;
}
export async function getEnvironmentForOrganization(
organizationSlug: string,
slug: string
) {
const organization = await prisma.organization.findUnique({
where: {
slug: organizationSlug,
},
include: {
environments: true,
},
});
if (!organization) {
return;
}
const environment = organization.environments.find(
(environment) => environment.slug === slug
);
return environment;
}
export const { commitSession, getSession } = createCookieSessionStorage({
cookie: {
name: "__environment",
+56
View File
@@ -4,11 +4,67 @@ import type {
Workflow,
ExternalSource,
} from ".prisma/client";
import { z } from "zod";
import { prisma } from "~/db.server";
export type { Workflow } from ".prisma/client";
export type { ExternalSource, EventRule } from ".prisma/client";
export const WorkflowMetadataSchema = z.object({
git: z
.object({
sha: z.string().optional().nullable(),
branch: z.string().optional().nullable(),
origin: z.string().optional().nullable(),
committer: z.string().optional().nullable(),
commitMessage: z.string().optional().nullable(),
committerDate: z.string().optional().nullable(),
})
.optional(),
packageMetadata: z
.object({
template: z.string().optional().nullable(),
})
.optional(),
});
export type WorkflowMetadata = z.infer<typeof WorkflowMetadataSchema>;
export function getRepositoryFromMetadata(metadata: unknown) {
const parsedMetadata = parseMetadata(metadata);
if (parsedMetadata.git && parsedMetadata.git.origin) {
return translateOriginToGitHubRepo(parsedMetadata.git.origin);
}
}
// Turns a git origin into a GitHub repo slug
// Could be in the following formats:
// https://github.com/triggerdotdev/trigger.dev.git
// git@github.com:triggerdotdev/trigger.dev.git
//
// And it should produce the following output:
// triggerdotdev/trigger.dev
//
// Use regex because git@github.com:triggerdotdev/trigger.dev.git isn't a valid url
function translateOriginToGitHubRepo(origin: string) {
const match = origin.match(/github\.com[:/]([^/]+\/[^/]+)\.git/);
if (match) {
return match[1];
}
return;
}
function parseMetadata(metadata: unknown) {
try {
return WorkflowMetadataSchema.parse(metadata);
} catch (e) {
return {};
}
}
export type WorkflowWithExternalSource = Workflow & {
externalSource: ExternalSource;
};
@@ -1,4 +1,9 @@
import type { SecureString } from "@trigger.dev/common-schemas";
import {
KVDeleteSchema,
KVGetSchema,
KVSetSchema,
SecureString,
} from "@trigger.dev/common-schemas";
import {
CustomEventSchema,
ErrorSchema,
@@ -105,6 +110,25 @@ async function parseStep(
type: "CUSTOM_EVENT" as const,
input: await CustomEventSchema.parseAsync(original.input),
};
case "KV_GET":
return {
...base,
type: "KV_GET" as const,
output: original.output,
input: await KVGetSchema.parseAsync(original.input),
};
case "KV_SET":
return {
...base,
type: "KV_SET" as const,
input: await KVSetSchema.parseAsync(original.input),
};
case "KV_DELETE":
return {
...base,
type: "KV_DELETE" as const,
input: await KVDeleteSchema.parseAsync(original.input),
};
case "OUTPUT":
return {
...base,
@@ -1,83 +0,0 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getServiceMetadatas } from "~/models/integrations.server";
import { renderMarkdown } from "~/services/renderMarkdown.server";
import type { TemplateListItem } from "./templateListPresenter.server";
export class WorkflowStartPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data({
organizationSlug,
userId,
templateId,
}: {
organizationSlug: string;
userId: string;
templateId?: string;
}) {
const appAuthorizations =
await this.#prismaClient.gitHubAppAuthorization.findMany({
where: {
organization: {
slug: organizationSlug,
},
user: {
id: userId,
},
},
select: {
id: true,
accountName: true,
installationId: true,
permissions: true,
repositorySelection: true,
},
});
const template = await this.#getTemplate(templateId);
const templates = await this.#prismaClient.template.findMany({
orderBy: {
priority: "asc",
},
where: { isLive: true },
});
return {
appAuthorizations,
templates,
template,
};
}
async #getTemplate(
templateId: string | undefined
): Promise<TemplateListItem | undefined> {
if (!templateId) {
return;
}
const template = await this.#prismaClient.template.findUnique({
where: {
id: templateId,
},
});
if (!template) {
return;
}
const serviceMetadatas = await getServiceMetadatas(true);
return {
...template,
services: template.services.map((s) => serviceMetadatas[s]),
docsHTML: renderMarkdown(template.markdownDocs),
};
}
}
+17 -14
View File
@@ -1,9 +1,7 @@
import { Outlet } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AppBody, AppLayout } from "~/components/layout/AppLayout";
import { Footer } from "~/components/layout/Footer";
import { Header } from "~/components/layout/Header";
import { ImpersonationBanner } from "~/components/ImpersonationBanner";
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
import { ProductHuntBanner } from "~/components/ProductHuntBanner";
import { getOrganizations } from "~/models/organization.server";
@@ -39,20 +37,25 @@ export const loader = async ({ request }: LoaderArgs) => {
);
};
const INTERCOM_APP_ID = "pfbctmiv";
export default function App() {
const { impersonationId } = useTypedLoaderData<typeof loader>();
return (
<AppLayout impersonationId={impersonationId}>
<NoMobileOverlay />
<ProductHuntBanner />
<Header />
<AppBody>
if (impersonationId) {
return (
<>
<ImpersonationBanner impersonationId={impersonationId} />
<NoMobileOverlay />
<Outlet />
</AppBody>
<Footer />
</AppLayout>
</>
);
}
return (
<>
<NoMobileOverlay />
<Outlet />
</>
);
}
/* <ProductHuntBanner /> */
+33 -28
View File
@@ -6,6 +6,8 @@ import {
import { Link } from "@remix-run/react";
import classNames from "classnames";
import { CopyTextPanel } from "~/components/CopyTextButton";
import { AppBody } from "~/components/layout/AppLayout";
import { Header } from "~/components/layout/Header";
import { Body } from "~/components/primitives/text/Body";
import { Header4 } from "~/components/primitives/text/Headers";
import { Tooltip } from "~/components/primitives/Tooltip";
@@ -18,36 +20,39 @@ export default function AppLayout() {
return (
<>
<div className="flex h-80 w-full items-center justify-center bg-slate-900/50">
<h1 className="relative bottom-6 text-4xl text-slate-400">
Your Organizations
</h1>
</div>
<div className="flex items-center justify-center">
<ul className="-mt-24 grid max-w-7xl grid-cols-2 gap-2 lg:grid-cols-3">
{organizations ? (
<OrganizationGrid organizations={organizations} />
) : (
<AppBody>
<Header context="workflows" />
<div className="flex h-80 w-full items-center justify-center bg-slate-900/50">
<h1 className="relative bottom-6 text-4xl text-slate-400">
Your Organizations
</h1>
</div>
<div className="flex items-center justify-center">
<ul className="-mt-24 grid max-w-7xl grid-cols-2 gap-2 lg:grid-cols-3">
{organizations ? (
<OrganizationGrid organizations={organizations} />
) : (
<li>
<Body>No organizations</Body>
</li>
)}
<li>
<Body>No organizations</Body>
<Link
to="orgs/new"
className={classNames(
"h-full border border-slate-700 hover:border-transparent hover:bg-[rgb(38,51,71)] hover:shadow-md",
boxClasses
)}
>
<PlusIcon className="h-10 w-10 text-green-500" />
<Header4 size="small" className="mb-10">
New Organization
</Header4>
</Link>
</li>
)}
<li>
<Link
to="orgs/new"
className={classNames(
"h-full border border-slate-700 hover:border-transparent hover:bg-[rgb(38,51,71)] hover:shadow-md",
boxClasses
)}
>
<PlusIcon className="h-10 w-10 text-green-500" />
<Header4 size="small" className="mb-10">
New Organization
</Header4>
</Link>
</li>
</ul>
</div>
</ul>
</div>
</AppBody>
</>
);
}
@@ -1,16 +1,27 @@
import { Outlet } from "@remix-run/react";
import { AppLayoutThreeCol } from "~/components/layout/AppLayout";
import {
SideMenuContainer,
OrganizationsSideMenu,
CurrentOrganizationSideMenu,
OrganizationSideMenuCollapsed,
} from "~/components/navigation/SideMenu";
import { useIsOrgChildPage } from "~/hooks/useIsOrgChildPage";
export default function OrganizationLayout() {
const isOrgChildPage = useIsOrgChildPage();
export default function Layout() {
return (
<>
<SideMenuContainer>
<OrganizationsSideMenu />
<Outlet />
</SideMenuContainer>
{isOrgChildPage ? (
<AppLayoutThreeCol>
<OrganizationSideMenuCollapsed />
<Outlet />
</AppLayoutThreeCol>
) : (
<AppLayoutThreeCol>
<CurrentOrganizationSideMenu />
<Outlet />
</AppLayoutThreeCol>
)}
</>
);
}
@@ -1,8 +1,13 @@
import { PlusIcon } from "@heroicons/react/24/outline";
import type { LoaderArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
import { AppBody, AppLayoutTwoCol } from "~/components/layout/AppLayout";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import { OrganizationsSideMenu } from "~/components/navigation/SideMenu";
import { PrimaryLink } from "~/components/primitives/Buttons";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { WorkflowList } from "~/components/workflows/workflowList";
@@ -43,30 +48,45 @@ export default function Page() {
}
return (
<Container>
{workflows.length === 0 ? (
<>
<Title>Create your first workflow</Title>
<div className="max-w-6xl">
<WorkflowOnboarding
templates={templates}
apiKey={currentEnv.apiKey}
/>
</div>
</>
) : (
<>
<Title>Workflows</Title>
<SubTitle>
{workflows.length} active workflow{workflows.length > 1 ? "s" : ""}
</SubTitle>
<WorkflowList
workflows={workflows}
currentOrganizationSlug={currentOrganization.slug}
/>
<CreateNewWorkflow />
</>
)}
</Container>
<AppLayoutTwoCol>
<OrganizationsSideMenu />
<AppBody>
<Header context="workflows" />
<Container>
{workflows.length === 0 ? (
<>
<Title>Create your first workflow</Title>
<div className="max-w-6xl">
<WorkflowOnboarding
templates={templates}
apiKey={currentEnv.apiKey}
/>
</div>
</>
) : (
<>
<div className="flex items-start justify-between">
<Title>Workflows</Title>
<PrimaryLink
to={`/orgs/${currentOrganization.slug}/workflows/new`}
rel="noreferrer"
>
<PlusIcon className="-ml-1 h-4 w-4" />
New workflow
</PrimaryLink>
</div>
<SubTitle>
{workflows.length} active workflow
{workflows.length > 1 ? "s" : ""}
</SubTitle>
<WorkflowList
workflows={workflows}
currentOrganizationSlug={currentOrganization.slug}
/>
</>
)}
</Container>
</AppBody>
</AppLayoutTwoCol>
);
}
@@ -7,8 +7,12 @@ import invariant from "tiny-invariant";
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
import type { Status } from "~/components/integrations/ConnectButton";
import { ConnectButton } from "~/components/integrations/ConnectButton";
import { AppBody, AppLayoutTwoCol } from "~/components/layout/AppLayout";
import { Container } from "~/components/layout/Container";
import { Header } from "~/components/layout/Header";
import { List } from "~/components/layout/List";
import { OrganizationsSideMenu } from "~/components/navigation/SideMenu";
import { PrimaryA } from "~/components/primitives/Buttons";
import { Body } from "~/components/primitives/text/Body";
import { Header3 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
@@ -41,82 +45,83 @@ export default function Integrations() {
invariant(organization, "Organization not found");
return (
<Container>
<Title>API Integrations</Title>
<div>
{connections.length === 0 ? (
<></>
) : (
<>
<SubTitle>
{connections.length} connected API
{connections.length > 1 ? "s" : ""}
</SubTitle>
<List>
{connections.map((connection) => {
return (
<li key={connection.id}>
<div className="flex items-center gap-4 px-4 py-4">
<ApiLogoIcon
integration={services[connection.apiIdentifier]}
size="regular"
/>
<div className="flex flex-col gap-2">
<div>
<Header3
size="extra-small"
className="truncate font-medium"
>
{connection.title}
</Header3>
<Body size="small" className="text-slate-400">
Added {formatDateTime(connection.createdAt)}
</Body>
<AppLayoutTwoCol>
<OrganizationsSideMenu />
<AppBody>
<Header context="workflows" />
<Container>
<div className="flex items-start justify-between">
<Title>API Integrations</Title>
<PrimaryA href="mailto:hello@trigger.dev">
<EnvelopeIcon className="-ml-1 h-5 w-5" />
Request an integration
</PrimaryA>
</div>
<div>
{connections.length === 0 ? (
<></>
) : (
<>
<SubTitle>
{connections.length} connected API
{connections.length > 1 ? "s" : ""}
</SubTitle>
<List>
{connections.map((connection) => {
return (
<li key={connection.id}>
<div className="flex items-center gap-4 px-4 py-4">
<ApiLogoIcon
integration={services[connection.apiIdentifier]}
size="regular"
/>
<div className="flex flex-col gap-2">
<div>
<Header3
size="extra-small"
className="truncate font-medium"
>
{connection.title}
</Header3>
<Body size="small" className="text-slate-400">
Added {formatDateTime(connection.createdAt)}
</Body>
</div>
</div>
</div>
</div>
</div>
</li>
);
})}
</List>
</>
)}
</div>
</li>
);
})}
</List>
</>
)}
</div>
<div>
<SubTitle>Add an API integration</SubTitle>
<div className="flex w-full flex-wrap gap-2">
{Object.values(services)
.sort((a, b) => a.name.localeCompare(b.name))
.map((integration) => (
<ConnectButton
key={integration.service}
integration={integration}
organizationId={organization.id}
className="group flex max-w-[160px] flex-col items-center gap-4 overflow-hidden rounded-md border border-slate-800 bg-slate-800 text-sm text-slate-200 shadow-md transition hover:bg-slate-800/30 disabled:opacity-50"
>
{(status) => (
<AddButtonContent integration={integration} status={status} />
)}
</ConnectButton>
))}
<a
href="mailto:hello@trigger.dev"
className="group flex max-w-[160px] flex-col items-center gap-4 overflow-hidden rounded-md border border-slate-800 bg-slate-800 text-sm text-slate-200 shadow-md transition hover:bg-slate-800/30 disabled:opacity-50"
>
<div className="relative flex w-full items-center justify-center border-b border-slate-800 bg-black/20 py-6">
<EnvelopeIcon className="h-20 w-20 text-slate-400" />
<div>
<SubTitle>Add an API integration</SubTitle>
<div className="flex w-full flex-wrap gap-2">
{Object.values(services)
.sort((a, b) => a.name.localeCompare(b.name))
.map((integration) => (
<ConnectButton
key={integration.service}
integration={integration}
organizationId={organization.id}
className="group flex max-w-[160px] flex-col items-center gap-4 overflow-hidden rounded-md border border-slate-800 bg-slate-800 text-sm text-slate-200 shadow-md transition hover:bg-slate-800/30 disabled:opacity-50"
>
{(status) => (
<AddButtonContent
integration={integration}
status={status}
/>
)}
</ConnectButton>
))}
</div>
<div className="flex flex-col items-center justify-center text-center leading-relaxed text-slate-400">
<span className="px-2.5">Need an integration?</span>
<span className="mb-4 px-6 text-base text-slate-200">
Let us know!
</span>
</div>
</a>
</div>
</div>
</Container>
</div>
</Container>
</AppBody>
</AppLayoutTwoCol>
);
}
@@ -0,0 +1,2 @@
export { loader } from "~/features/ee/projects/routes/projects";
export { default } from "~/features/ee/projects/routes/projects";
@@ -0,0 +1,2 @@
export { loader } from "~/features/ee/projects/routes/projects/$projectP";
export { default } from "~/features/ee/projects/routes/projects/$projectP";
@@ -0,0 +1,5 @@
export {
loader,
action,
} from "~/features/ee/projects/routes/projects/$projectP/deploys/$deployId";
export { default } from "~/features/ee/projects/routes/projects/$projectP/deploys/$deployId";
@@ -0,0 +1,2 @@
export { loader } from "~/features/ee/projects/routes/projects/$projectP/deploys";
export { default } from "~/features/ee/projects/routes/projects/$projectP/deploys";
@@ -0,0 +1,5 @@
export {
loader,
action,
} from "~/features/ee/projects/routes/projects/$projectP/index";
export { default } from "~/features/ee/projects/routes/projects/$projectP/index";
@@ -0,0 +1,2 @@
export { loader } from "~/features/ee/projects/routes/projects/$projectP/logs";
export { default } from "~/features/ee/projects/routes/projects/$projectP/logs";
@@ -0,0 +1,2 @@
export { action } from "~/features/ee/projects/routes/projects/$projectP/settings";
export { default } from "~/features/ee/projects/routes/projects/$projectP/settings";
@@ -0,0 +1 @@
export { default } from "~/features/ee/projects/routes/projects/coming-soon";
@@ -0,0 +1,2 @@
export { loader } from "~/features/ee/projects/routes/projects/index";
export { default } from "~/features/ee/projects/routes/projects/index";
@@ -0,0 +1,2 @@
export { loader, action } from "~/features/ee/projects/routes/projects/new";
export { default } from "~/features/ee/projects/routes/projects/new";

Some files were not shown because too many files have changed in this diff Show More