Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcfe6b62b | |||
| c593132663 | |||
| ff7bdd7619 | |||
| c229fa31fc | |||
| 632f76cbb9 | |||
| 5dae509aa9 | |||
| 1b902dabfa | |||
| a1043a3b28 | |||
| 22c682bf4c | |||
| 0b67b51ae4 | |||
| 1673d4527a | |||
| 5e762a9cbf | |||
| 406be0107e | |||
| 4ab4174cb2 | |||
| 78e6ed9bfc | |||
| f813e59b0c | |||
| 2992a855f9 | |||
| f172a0ee1a | |||
| 3c74e4f5f6 | |||
| bf4eefe4fd | |||
| 2e81d6fe20 | |||
| 77f243a14a | |||
| 4245c21248 | |||
| e6d276de03 | |||
| 0e3c3a78d7 | |||
| fa28005f40 | |||
| c109a9f31c | |||
| c1dd5e92fe | |||
| 441183975c | |||
| 0f9c0a8d06 | |||
| 5beba8f12e | |||
| 9b7102605f | |||
| 0618cd0d7a | |||
| bc7f11fb57 | |||
| 19d731eebb | |||
| e2b183bd96 | |||
| 80709c5415 | |||
| 22e3c7f5f2 | |||
| 843231c410 | |||
| 1a33bdd3ae | |||
| c0300c6bec | |||
| bffa5ef0e2 | |||
| 4e95232d59 | |||
| ca64356f12 | |||
| c2c1b0feb4 | |||
| 57afaf1995 | |||
| 4c965ed5a2 | |||
| 0462141e1b | |||
| ce39397c57 | |||
| 55cb81f7af | |||
| f7a4e69025 | |||
| 55ac0019b7 | |||
| b87a3dd2ba | |||
| 41c751c081 | |||
| 1f34a22bd8 | |||
| 10ef03eee0 | |||
| bef6de13db | |||
| bd356663cc | |||
| 1670c3e19b | |||
| aa35555356 | |||
| 1520be20c4 | |||
| 9cd49a40d5 | |||
| d27f11e26c | |||
| 9aff64183b | |||
| 5e32c8498b | |||
| bd2f2d442d | |||
| 328ff206d7 | |||
| 774a0fe9e3 | |||
| 3eb76739cc | |||
| f00f6ae1bd | |||
| 7798c0166d | |||
| 9c63687d56 | |||
| 28cad34c5f | |||
| 86d345bffc | |||
| 9cdab61eaf | |||
| 48b35c47b4 | |||
| 349d7f38cc | |||
| 7cbc279407 | |||
| 301d500c3d | |||
| 4150ba8786 | |||
| f39bc44eec | |||
| 4fabd481e6 | |||
| a374338ac5 | |||
| a7337478d0 | |||
| a7b37e9f7b | |||
| 48e0cff123 | |||
| 8a4cc312a1 | |||
| f008f803b8 | |||
| b43224c353 | |||
| 8bb9798ff4 | |||
| 86ae6f927a | |||
| c0693ac508 | |||
| ab512157a5 | |||
| 86475d4e3c | |||
| c5084209eb | |||
| eada69040c | |||
| 6deabb21ba | |||
| 2bb324752e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Fixed an error message
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Added kv storage to persist data in between runs and between workflows
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Fix ESM error by dynamically importing ESM packages (chalk, terminal-link, etc.)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
SDK now passes through the project ID from the env var
|
||||
+2
-1
@@ -45,4 +45,5 @@ yarn-error.log*
|
||||
.output
|
||||
apps/**/public/build
|
||||
.tests-container-id.txt
|
||||
.sentryclirc
|
||||
.sentryclirc
|
||||
.buildt
|
||||
@@ -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
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
© {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>© {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
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
-32
@@ -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;
|
||||
}
|
||||
+12
-21
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 /> */
|
||||
|
||||
@@ -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";
|
||||
+5
@@ -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";
|
||||
+2
@@ -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";
|
||||
+2
@@ -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
Reference in New Issue
Block a user