Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d22a460555 | |||
| 9ba2a217a4 | |||
| 39885a427f | |||
| ccb0bc510a | |||
| 56d66ee07c | |||
| 4ca8887972 | |||
| 89bffc066c | |||
| 34ca7667d3 | |||
| 3e327acc0f | |||
| 77ad4127cb | |||
| 8a5076aacf | |||
| 5399f6bfb7 | |||
| ecef199660 | |||
| 4acfb8f4bb | |||
| 2ef278db67 | |||
| c7a55804d9 | |||
| da6a66efff | |||
| 7c36a1a4b0 | |||
| 225effb599 | |||
| 98eb6ed4f9 | |||
| 3069ebf0d8 | |||
| e133e628ca | |||
| 098932ea96 | |||
| 65f960e883 | |||
| ccbeff47e6 | |||
| 7c8f2df105 | |||
| fd44dabfe0 | |||
| 5daed3f69d | |||
| 596bf78e55 | |||
| 6ca66b76f4 | |||
| 29ef0395ce | |||
| 55d1f8c677 | |||
| 9835f4ec55 | |||
| 7fae10db23 | |||
| 8cf1f0a37d | |||
| dba4313c5c | |||
| 506613dc92 | |||
| 764df23d19 | |||
| 4b961a6ae2 | |||
| 8757fdceef | |||
| 2404e88ac5 | |||
| 88b36f5090 | |||
| b73ae3f927 | |||
| b605b892ac | |||
| 233316f7e8 | |||
| 1b90ffbb8c | |||
| b45ca4e146 | |||
| 25d15578f7 | |||
| fe865a0f49 | |||
| 0ed93a748e | |||
| e02320f65d | |||
| 85a543d8ec | |||
| 10ceb85a92 | |||
| c405ae7117 | |||
| 3687fcb61e | |||
| d4ccdf7105 | |||
| e08b4569e5 | |||
| 79da0ca9b5 | |||
| 3aca603a33 | |||
| c9e97d6b78 | |||
| 01633c9c03 | |||
| 691990d79e | |||
| b2ba403dd3 | |||
| 1d47cab69f | |||
| e23047f9ad | |||
| 68d32429b6 | |||
| 36ac79ac66 | |||
| ca94f0cac3 | |||
| a5d8e453a5 | |||
| c332519e72 | |||
| 52112c3bfc | |||
| eae294a332 | |||
| 465cd0335c | |||
| 35dbaedf69 | |||
| c11a77f50b | |||
| fb52b9efea | |||
| 0896b9fffc | |||
| 3a2dd983c5 | |||
| a627ca67d1 | |||
| afc180aa70 | |||
| 393af1b7c5 | |||
| 6a91fb89b8 | |||
| df7d1de16d | |||
| 8e8ed4a3bf | |||
| 8fc8f57b39 | |||
| 9ebd91ccec | |||
| 665f7c9756 | |||
| 928a632e23 | |||
| 74db2de1bc | |||
| 93acca6c3c | |||
| ebe079d83c | |||
| d272996de3 | |||
| 531bd4970d | |||
| 5c9eb25b5a | |||
| c970e892a7 | |||
| a867b6e5ae | |||
| d44abbd0fc | |||
| 1cc680ac1e | |||
| 9b049bc480 | |||
| c24a23b551 | |||
| ee1ae1fca6 | |||
| 8e5ef176a4 | |||
| 58b6b1aa0d | |||
| 9c0ae1459f | |||
| 2f15a84320 | |||
| a49a0ff416 | |||
| b703ffed29 | |||
| b4f9b70ae2 | |||
| 51bb4c887a | |||
| ba71f959e2 | |||
| bc7bbd4576 | |||
| 5fe23e4b3f | |||
| 7b3b2e0d8e | |||
| 3900ddadce | |||
| ca9e827bd3 | |||
| 04e936b69b | |||
| 98ef170299 | |||
| e69ffd314a | |||
| 782d4f75ae | |||
| b6de469d07 | |||
| 0dd3447c31 | |||
| a5a5d3ae21 | |||
| ee3619bbb1 | |||
| d9ad72446e | |||
| a56f9af9fe | |||
| ece6ca678a | |||
| 6243ae30bb | |||
| cec20b96fd | |||
| ea4d15536c | |||
| 2fa5780076 | |||
| 914a394d15 | |||
| 80b1d8c6b1 | |||
| 129c6022a0 | |||
| 1ca3b99ace | |||
| dbbc7760f3 | |||
| ff3cd97a3b | |||
| 3a1b0c486a | |||
| 1f462eaa2f | |||
| f537778dc7 | |||
| 974bcc49e8 | |||
| 60cbb87b08 | |||
| 1281d40e4b | |||
| 116766f398 | |||
| c815f28c84 | |||
| c092c0f9db | |||
| a86f36cefa | |||
| 5a6e79e0c0 | |||
| 7cb20fbead | |||
| a561bca315 | |||
| 3f45738612 | |||
| 05d3b08ae5 | |||
| 3f8b6d8fce | |||
| 0a7ad232fb | |||
| 04f863c42a | |||
| ae5ec86d0b | |||
| f243eab9c9 | |||
| a5cba375ae | |||
| c9ebe7f114 | |||
| f4f87ad573 | |||
| a98b62652c | |||
| 38965d8921 | |||
| 14cffd4e5c | |||
| 68107565a4 | |||
| 8a263c8ab9 | |||
| 6fecf2dbc0 | |||
| a8db8ce4e4 | |||
| a7ca1222d7 | |||
| c9733f357f | |||
| ba61bfe3b9 | |||
| 89eaef495f | |||
| 6a379e4e91 | |||
| c0b815c363 | |||
| 598906fc4c | |||
| b77ece1567 | |||
| a73dc80177 | |||
| 2496917a7a | |||
| dc53f0f432 | |||
| 87788f29c9 | |||
| 6ede68648a | |||
| f24b5b770d | |||
| cb81d6c835 | |||
| 72748603ab | |||
| f96f1e91a2 | |||
| 21c8b847e5 | |||
| b39f79f0ab | |||
| 90b8797fbb | |||
| 107f2e7bd5 | |||
| b8477ea2b0 | |||
| 864498ea85 | |||
| 2e21bc1563 | |||
| 1477a2e309 | |||
| 2081cb15b8 | |||
| ff551c5cbd | |||
| 794b25a988 | |||
| 5fdb43ecd1 | |||
| 80997bcd6d | |||
| 194f336b84 | |||
| 2e02743170 | |||
| 1642fd7baf | |||
| c6449126b1 | |||
| 1ccf38e440 | |||
| 29b773cd36 | |||
| 73fb7cfeb3 | |||
| 5431638926 | |||
| 933a14b44c | |||
| 4ff07f8a94 | |||
| 0afdb4cc7c | |||
| af9957c085 | |||
| 5c4275619b | |||
| 892fd9f212 | |||
| cbe9317a86 | |||
| 6baf9e5294 | |||
| a823421324 | |||
| 0e919f56f2 | |||
| f90960fc13 | |||
| 2306217697 | |||
| cfe4b5409a | |||
| 223768a025 | |||
| 1fd26ff98b | |||
| 8d34c63c2d | |||
| ff04c986a1 | |||
| 7d6430dc46 | |||
| b289aa31da | |||
| 0508de6258 | |||
| 56a063e586 | |||
| d490bc5cbf | |||
| 6d9dfbc75d | |||
| 6406924b02 | |||
| 2156e15266 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue with scoped packages in additionalPackages option
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Add an e2e suite to test compiling with v3 CLI.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/shopify": patch
|
||||
---
|
||||
|
||||
improved error messages when a shopify webhook fails to register
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
cli v3: increase otel force flush timeout to 30s from 500ms
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixing missing logs when importing client @opentelemetry/api
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Better handle issues with resolving dependency versions during deploy
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: Environment variable management API and SDK, along with resolveEnvVars CLI hook
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Support triggering tasks with non-URL friendly characters in the ID
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
trigger.dev: patch
|
||||
---
|
||||
|
||||
Fix TypeScript inclusion in tsconfig.json for `cli-v3 init`
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Usage tracking
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Fix for calling trigger and passing a custom queue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Adding some additional telemetry during deploy to help debug issues
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add the "log" level back in as an alias to "info"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: Remove aggressive otel flush timeouts in dev/prod
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix issue when using SDK in non-node environments by scoping the stream import with node:
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Better handle uncaught exceptions
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add configure function to be able to configure the SDK manually
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Output stderr logs on dev worker failure
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Use global setTimeout to ensure cross-runtime support
|
||||
+47
-1
@@ -44,42 +44,62 @@
|
||||
"@trigger.dev/yalt": "2.3.18"
|
||||
},
|
||||
"changesets": [
|
||||
"afraid-sheep-joke",
|
||||
"angry-eagles-trade",
|
||||
"beige-pears-explode",
|
||||
"beige-pens-dance",
|
||||
"big-tomatoes-deliver",
|
||||
"blue-pumas-whisper",
|
||||
"breezy-gorillas-mate",
|
||||
"brown-spies-burn",
|
||||
"chilled-hornets-move",
|
||||
"clean-pianos-listen",
|
||||
"clever-apes-collect",
|
||||
"clever-carrots-travel",
|
||||
"clever-donkeys-hunt",
|
||||
"cool-comics-burn",
|
||||
"cool-glasses-bake",
|
||||
"cuddly-feet-approve",
|
||||
"dry-walls-check",
|
||||
"dull-mangos-press",
|
||||
"eight-pumas-float",
|
||||
"eleven-paws-join",
|
||||
"famous-boats-tease",
|
||||
"few-students-share",
|
||||
"five-toes-destroy",
|
||||
"funny-swans-destroy",
|
||||
"gorgeous-gorillas-compete",
|
||||
"green-bags-wink",
|
||||
"hot-buckets-behave",
|
||||
"hot-fishes-retire",
|
||||
"hot-wasps-sin",
|
||||
"itchy-chairs-itch",
|
||||
"khaki-apricots-design",
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
"late-steaks-behave",
|
||||
"lazy-files-lay",
|
||||
"lemon-jobs-repair",
|
||||
"light-bulldogs-press",
|
||||
"light-dragons-complain",
|
||||
"little-crabs-cross",
|
||||
"long-fireants-search",
|
||||
"loud-actors-remember",
|
||||
"lovely-drinks-flash",
|
||||
"many-ligers-pump",
|
||||
"mighty-camels-joke",
|
||||
"mighty-flowers-train",
|
||||
"mighty-parrots-sin",
|
||||
"nasty-jars-pump",
|
||||
"new-pants-beg",
|
||||
"new-rivers-tell",
|
||||
"nice-bulldogs-turn",
|
||||
"ninety-pets-travel",
|
||||
"odd-poets-own",
|
||||
"pink-pumas-rhyme",
|
||||
"plenty-ducks-beam",
|
||||
"polite-ducks-switch",
|
||||
"polite-pears-grow",
|
||||
"polite-rockets-matter",
|
||||
"poor-flowers-cross",
|
||||
"purple-garlics-shop",
|
||||
@@ -89,32 +109,58 @@
|
||||
"rich-kangaroos-unite",
|
||||
"rotten-beers-refuse",
|
||||
"rotten-dryers-exercise",
|
||||
"rude-toys-compare",
|
||||
"selfish-ducks-sort",
|
||||
"serious-hats-rest",
|
||||
"shaggy-spoons-taste",
|
||||
"sharp-emus-compare",
|
||||
"sharp-zebras-serve",
|
||||
"shiny-coats-cry",
|
||||
"silly-buses-obey",
|
||||
"silly-suits-switch",
|
||||
"silver-doors-juggle",
|
||||
"six-ligers-exist",
|
||||
"six-rats-hunt",
|
||||
"sixty-insects-watch",
|
||||
"slow-buses-own",
|
||||
"slow-kiwis-hide",
|
||||
"slow-sloths-retire",
|
||||
"smart-needles-move",
|
||||
"smart-olives-eat",
|
||||
"sour-pugs-teach",
|
||||
"spicy-lamps-smoke",
|
||||
"spicy-terms-bow",
|
||||
"strange-ghosts-matter",
|
||||
"strange-sheep-pull",
|
||||
"strong-lemons-add",
|
||||
"strong-owls-know",
|
||||
"strong-phones-smoke",
|
||||
"stupid-adults-sniff",
|
||||
"stupid-bulldogs-applaud",
|
||||
"sweet-ducks-remember",
|
||||
"sweet-lizards-press",
|
||||
"swift-dragons-peel",
|
||||
"tall-bees-wave",
|
||||
"tame-guests-know",
|
||||
"tender-moose-tell",
|
||||
"tender-oranges-rhyme",
|
||||
"tender-turkeys-compete",
|
||||
"thick-carrots-sneeze",
|
||||
"thin-parents-heal",
|
||||
"thirty-islands-kiss",
|
||||
"tidy-balloons-suffer",
|
||||
"tidy-dryers-sleep",
|
||||
"tidy-tomatoes-explain",
|
||||
"tiny-doors-type",
|
||||
"tiny-elephants-scream",
|
||||
"tricky-bulldogs-heal",
|
||||
"tricky-keys-attack",
|
||||
"tricky-ladybugs-unite",
|
||||
"two-pumas-wait"
|
||||
"twelve-knives-notice",
|
||||
"two-pumas-wait",
|
||||
"violet-clocks-notice",
|
||||
"warm-olives-provide",
|
||||
"warm-planes-taste",
|
||||
"young-snails-sell"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Capture and display stderr on index failures
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: [prod] force flush timeout should be 1s
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add callback to checkpoint created message
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Make deduplicationKey required when creating/updating a schedule
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Improved ESM module require error detection logic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Set the deploy timeout to 3mins from 1min
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: vercel edge runtime support
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Fix uncaught provider exception
|
||||
- Remove unused provider messages
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: fix otel flushing causing CLEANUP ack timeout errors by always setting a forceFlushTimeoutMillis value
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@trigger.dev/core-apps": patch
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
- Fix init command SDK pinning
|
||||
- Show --api-url / -a flag where needed
|
||||
- CLI now also respects `TRIGGER_TELEMETRY_DISABLED`
|
||||
- Dedicated docker checkpoint test function
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v2: Better handle recovering from platform communication errors by auto-yielding back to the platform in case of temporary API failures
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
When a v2 run hits the rate limit, reschedule with the reset date
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
fix: allow command login to read api url from cli args
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Management SDK overhaul and adding the runs.list API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Adding SDK functions for triggering tasks in a typesafe way, without importing task file
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Added timezone support to schedules
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
v3: Include presigned urls for downloading large payloads and outputs when using runs.retrieve
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Ensure @trigger.dev/sdk and @trigger.dev/core are always in the list of deployed dependencies
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix for typo in v3 CLI login command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix 3rd party otel propagation from breaking our Task Events data from being properly correlated to the correct trace
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Clear paused states before retry
|
||||
- Detect and handle unrecoverable worker errors
|
||||
- Remove checkpoints after successful push
|
||||
- Permanently switch to DO hosted busybox image
|
||||
- Fix IPC timeout issue, or at least handle it more gracefully
|
||||
- Handle checkpoint failures
|
||||
- Basic chaos monkey for checkpoint testing
|
||||
- Stack traces are back in the dashboard
|
||||
- Display final errors on root span
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: fix missing init output in task run function when no middleware is defined
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix jsonc-parser import
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve handling of IPC timeouts and fix checkpoint cancellation after failures
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
v3: Prevent legacy-peer-deps=true from breaking deploys
|
||||
|
||||
When a global `.npmrc` file includes `legacy-peer-deps=true`, deploys would fail on the `npm ci` step because the package-lock.json wouldn't match the `package.json` file. This is because inside the image build, the `.npmrc` file would not be picked up and so `legacy-peer-deps` would end up being false (which is the default). This change forces the `package-lock.json` file to be created using `legacy-peer-deps=false`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Increase cleanup IPC timeout
|
||||
+11
-4
@@ -11,15 +11,22 @@ REMIX_APP_PORT=3030
|
||||
APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
CLOUD_ENV=development
|
||||
V3_ENABLED=true
|
||||
|
||||
# Redis is used for concurrency control
|
||||
# REDIS_HOST="localhost"
|
||||
# REDIS_PORT="6379"
|
||||
# REDIS_TLS_DISABLED="true"
|
||||
# Redis is used for the v3 queuing and v2 concurrency control
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_TLS_DISABLED="true"
|
||||
|
||||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3030/otel"
|
||||
DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
# WHITELISTED_EMAILS="authorized@yahoo\.com|authorized@gmail\.com"
|
||||
# Accounts with these emails will get global admin rights. This grants access to the admin UI.
|
||||
# ADMIN_EMAILS="admin@example\.com|another-admin@example\.com"
|
||||
# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication.
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
name: "🧪 E2E Tests"
|
||||
name: "E2E"
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
package:
|
||||
description: The identifier of the job to run
|
||||
default: webapp
|
||||
required: false
|
||||
type: string
|
||||
jobs:
|
||||
e2e:
|
||||
name: "🧪 E2E Tests"
|
||||
cli-v3:
|
||||
name: "🧪 CLI v3 tests"
|
||||
if: inputs.package == 'cli-v3' || inputs.package == ''
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package-manager: ["npm", "pnpm", "yarn"]
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v3
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile --filter trigger.dev...
|
||||
|
||||
- name: 🔧 Build v3 cli monorepo dependencies
|
||||
run: pnpm run build --filter trigger.dev^...
|
||||
|
||||
- name: 🔧 Build worker template files
|
||||
run: pnpm --filter trigger.dev run build:workers
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
PM=${{ matrix.package-manager }} pnpm --filter trigger.dev run test:e2e
|
||||
webapp:
|
||||
name: "🧪 Webapp tests"
|
||||
if: inputs.package == 'webapp' || inputs.package == ''
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: 🐳 Login to Docker Hub
|
||||
|
||||
@@ -29,4 +29,6 @@ jobs:
|
||||
|
||||
# e2e:
|
||||
# uses: ./.github/workflows/e2e.yml
|
||||
# with:
|
||||
# package: webapp
|
||||
# secrets: inherit
|
||||
|
||||
@@ -39,11 +39,25 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "::set-output name=version::${IMAGE_TAG}"
|
||||
|
||||
- name: 🔢 Get the commit hash
|
||||
id: get_commit
|
||||
run: |
|
||||
echo ::set-output name=sha_short::$(echo ${{ github.sha }} | cut -c1-7)
|
||||
|
||||
- name: 📛 Set the tags
|
||||
id: set_tags
|
||||
run: |
|
||||
ref_without_tag=ghcr.io/triggerdotdev/trigger.dev
|
||||
image_tags=$ref_without_tag:${{ steps.get_version.outputs.version }}
|
||||
|
||||
# if it's a versioned tag, also tag it as latest
|
||||
if [[ "${{ github.ref_name }}" == v.docker.* ]]; then
|
||||
image_tags=$image_tags,$ref_without_tag:latest
|
||||
fi
|
||||
|
||||
echo "IMAGE_TAGS=${image_tags}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 🐙 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -56,6 +70,5 @@ jobs:
|
||||
with:
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
ghcr.io/triggerdotdev/trigger.dev:${{ steps.get_version.outputs.version }}
|
||||
tags: ${{ steps.set_tags.outputs.IMAGE_TAGS }}
|
||||
push: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: "🚢 Publish Infra Images"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
tags:
|
||||
- "infra-dev-*"
|
||||
@@ -29,9 +30,6 @@ permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
AWS_REGION: us-east-1
|
||||
|
||||
@@ -39,7 +37,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
package: [coordinator, kubernetes-provider]
|
||||
package: [coordinator, docker-provider, kubernetes-provider]
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
@@ -48,20 +46,40 @@ jobs:
|
||||
|
||||
- name: Generate image reference
|
||||
id: prep
|
||||
# WARNING: This step expects the workflow to have been triggered by a specific tag format of: infra-${env}-*
|
||||
run: |
|
||||
env=$(echo ${{ github.ref_name }} | cut -d- -f2)
|
||||
sha=${GITHUB_SHA::7}
|
||||
ts=$(date +%s)
|
||||
# set image repo
|
||||
if [[ "${{ matrix.package }}" == *-provider ]]; then
|
||||
provider_type=$(echo ${{ matrix.package }} | cut -d- -f1)
|
||||
provider_type=$(echo "${{ matrix.package }}" | cut -d- -f1)
|
||||
repository=provider/${provider_type}
|
||||
else
|
||||
repository=${{ matrix.package }}
|
||||
repository="${{ matrix.package }}"
|
||||
fi
|
||||
echo "IMAGE_TAG=${env}-${sha}-${ts}" >> "$GITHUB_OUTPUT"
|
||||
echo "REPOSITORY=${repository}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# set image tag
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "${{ github.ref_name }}" == infra-*-* ]]; then
|
||||
env=$(echo ${{ github.ref_name }} | cut -d- -f2)
|
||||
sha=$(echo ${{ github.sha }} | head -c7)
|
||||
ts=$(date +%s)
|
||||
image_tag=${env}-${sha}-${ts}
|
||||
elif [[ "${{ github.ref_name }}" == v.docker.* ]]; then
|
||||
version="${GITHUB_REF_NAME#v.docker.}"
|
||||
image_tag="v${version}"
|
||||
elif [[ "${{ github.ref_name }}" == build-* ]]; then
|
||||
image_tag="${GITHUB_REF_NAME#build-}"
|
||||
else
|
||||
echo "Invalid tag: ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${{ github.ref_name }}" == "main" ]]; then
|
||||
image_tag="main"
|
||||
else
|
||||
echo "Invalid reference: ${{ github.ref }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "IMAGE_TAG=${image_tag}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -92,3 +110,12 @@ jobs:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: ${{ steps.prep.outputs.REPOSITORY }}
|
||||
IMAGE_TAG: ${{ steps.prep.outputs.IMAGE_TAG }}
|
||||
|
||||
- name: 🐙 Push 'latest' to GitHub Container Registry
|
||||
if: startsWith(github.ref_name, 'v.docker.')
|
||||
run: |
|
||||
docker tag infra_image $REGISTRY/$REPOSITORY:latest
|
||||
docker push $REGISTRY/$REPOSITORY:latest
|
||||
env:
|
||||
REGISTRY: ghcr.io/triggerdotdev
|
||||
REPOSITORY: ${{ steps.prep.outputs.REPOSITORY }}
|
||||
|
||||
@@ -49,11 +49,18 @@ jobs:
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
# e2e:
|
||||
# uses: ./.github/workflows/e2e.yml
|
||||
# secrets: inherit
|
||||
e2e:
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
publish:
|
||||
needs: [typecheck, units]
|
||||
uses: ./.github/workflows/publish-docker.yml
|
||||
secrets: inherit
|
||||
|
||||
publish-infra:
|
||||
needs: [typecheck, units]
|
||||
uses: ./.github/workflows/publish-infra.yml
|
||||
secrets: inherit
|
||||
|
||||
Vendored
+8
@@ -45,6 +45,14 @@
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug V3 Management",
|
||||
"command": "pnpm run management",
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
|
||||
+106
-88
@@ -10,13 +10,14 @@ Thank you for helping us make Trigger.dev even better! 🤩
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
requests should be made against. The changes on the `main`
|
||||
branch are tagged into a release monthly.
|
||||
branch are tagged into a release periodically.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) version >=16.x
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 7
|
||||
- [Node.js](https://nodejs.org/en) version 20.11.1
|
||||
- [pnpm package manager](https://pnpm.io/installation) version 8.15.5
|
||||
- [Docker](https://www.docker.com/get-started/)
|
||||
- [protobuf](https://github.com/protocolbuffers/protobuf)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -33,15 +34,19 @@ branch are tagged into a release monthly.
|
||||
```
|
||||
cd trigger.dev
|
||||
```
|
||||
3. Install the required packages using pnpm.
|
||||
3. Ensure you are on the correct version of Node.js (20.11.1). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository.
|
||||
|
||||
4. Run `corepack enable` to use the correct version of pnpm (`8.15.5`) as specified in the root `package.json` file.
|
||||
|
||||
5. Install the required packages using pnpm.
|
||||
```
|
||||
pnpm i
|
||||
```
|
||||
4. Create your `.env` file
|
||||
6. Create your `.env` file
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
5. Open it and generate a new value for `ENCRYPTION_KEY`:
|
||||
7. Open it and generate a new value for `ENCRYPTION_KEY`:
|
||||
|
||||
`ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command:
|
||||
|
||||
@@ -51,7 +56,7 @@ branch are tagged into a release monthly.
|
||||
|
||||
Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method.
|
||||
|
||||
6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
|
||||
8. Start Docker. This starts the required services like Postgres & Redis. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
|
||||
|
||||
```
|
||||
pnpm run docker
|
||||
@@ -59,19 +64,15 @@ branch are tagged into a release monthly.
|
||||
|
||||
This will also start and run a local instance of [pgAdmin](https://www.pgadmin.org/) on [localhost:5480](http://localhost:5480), preconfigured with email `admin@example.com` and pwd `admin`. Then use `postgres` as the password to the Trigger.dev server.
|
||||
|
||||
7. Migrate the database
|
||||
9. Migrate the database
|
||||
```
|
||||
pnpm run db:migrate
|
||||
```
|
||||
8. Build the app
|
||||
```
|
||||
pnpm run build --filter webapp
|
||||
```
|
||||
9. Run the seed script
|
||||
```
|
||||
pnpm run db:seed
|
||||
```
|
||||
10. Run the app. See the section below.
|
||||
10. Build the server app
|
||||
```
|
||||
pnpm run build --filter webapp
|
||||
```
|
||||
11. Run the app. See the section below.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -83,99 +84,96 @@ branch are tagged into a release monthly.
|
||||
|
||||
It should run on port `3030`: [http://localhost:3030](http://localhost:3030/)
|
||||
|
||||
2. Once the app is running click the magic link button and enter your email.
|
||||
3. Check your terminal, the magic link email should have printed out as following:
|
||||
2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard.
|
||||
|
||||
```sh
|
||||
webapp:dev: Log in to Trigger.dev
|
||||
webapp:dev:
|
||||
webapp:dev: Click here to log in with this magic link
|
||||
webapp:dev: [http://localhost:3030/magic?token=U2FsdGVkX18OvB0JxgaswTLCSbaRz%2FY82TN0EZWhSzFyZYwgG%2BIzKVTkeiaOtWfotPw7F8RwFzCHh53aBpMEu%2B%2B%2FItb%2FcJYh89MSjc3Pz92bevoEjqxSQ%2Ff%2BZbks09JOpqlBbYC3FzGWC8vuSVFBlxqLXxteSDLthZSUaC%2BS2LaA%2BJgp%2BLO7hgjAaC2lXbCHrM7MTgTdXOFt7i0Dvvuwz6%2BWY25RnfomZOPqDsyH0xz8Q2rzPTz0Xu53WSXrZ1hd]
|
||||
webapp:dev:
|
||||
webapp:dev: If you didn't try to log in, you can safely ignore this email.
|
||||
```
|
||||
## Manual testing using v3-catalog
|
||||
|
||||
Paste the magic link shown in your terminal into your browser to login.
|
||||
We use the `<root>/references/v3-catalog` subdirectory as a staging ground for testing changes to the SDK (`@trigger.dev/sdk` at `<root>/packages/trigger-sdk`), the Core package (`@trigger.dev/core` at `<root>packages/core`), the CLI (`trigger.dev` at `<root>/packages/cli-v3`) and the platform (The remix app at `<root>/apps/webapp`). The instructions below will get you started on using the `v3-catalog` for local development of Trigger.dev (v3).
|
||||
|
||||
## Adding and running migrations
|
||||
### First-time setup
|
||||
|
||||
1. Modify packages/database/prisma/schema.prisma file
|
||||
2. Change directory to the packages/database folder
|
||||
```sh
|
||||
cd packages/database
|
||||
```
|
||||
3. Generate the Prisma client
|
||||
First, make sure you are running the webapp according to the instructions above. Then:
|
||||
|
||||
```sh
|
||||
pnpm run generate
|
||||
```
|
||||
1. In Postgres go to the "Organizations" table and on your org set the `v3Enabled` column to `true`.
|
||||
|
||||
The above updates the prisma client generated into node_modules/.prisma/client folder. This helps with typing of relevant prisma models. It ensures typescript
|
||||
recognizes fields added or removed from a model and type-checks appropriately.
|
||||
2. Visit http://localhost:3030 in your browser and create a new V3 project called "v3-catalog". If you don't see an option for V3, you haven't set the `v3Enabled` flag to true.
|
||||
|
||||
4. Create and apply the migrations
|
||||
3. In Postgres go to the "Projects" table and for the project you create change the `externalRef` to `yubjwjsfkxnylobaqvqz`.
|
||||
|
||||
```
|
||||
pnpm run db:migrate:dev
|
||||
```
|
||||
|
||||
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
|
||||
|
||||
5. Commit generated migrations as well as changes to the schema.prisma file
|
||||
6. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
|
||||
|
||||
## Testing CLI changes
|
||||
|
||||
To test CLI changes, follow the steps below:
|
||||
|
||||
1. Build the CLI and watch for changes
|
||||
4. Build the CLI
|
||||
|
||||
```sh
|
||||
cd packages/cli
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. Open a new Terminal window and run the webapp locally and then create a new project in the dashboard. Copy out the dev API key.
|
||||
|
||||
3. Create a new temporary Next.js app in references directory
|
||||
|
||||
```sh
|
||||
cd ./references
|
||||
pnpm create next-app@latest test-cli --ts --no-eslint --tailwind --app --src-dir --import-alias "@/*"
|
||||
```
|
||||
|
||||
4. Then once that's finished, add the `@trigger.dev/cli` to the `devDependencies` of the newly created Next.js app's `package.json` file, like so:
|
||||
|
||||
```json
|
||||
{
|
||||
// other package.json properties
|
||||
"devDependencies": { "@trigger.dev/cli": "workspace:*" }
|
||||
}
|
||||
```
|
||||
|
||||
5. Back in the terminal, navigate into the reference, and initialize the CLI. When prompted, select `self-hosted` and enter `localhost:3030` if you are testing against the local instance of Trigger.dev, or you can just use the Trigger.dev cloud. When asked for an API key, use the key you copied earlier.
|
||||
|
||||
```sh
|
||||
cd ./test-cli
|
||||
# Build the CLI
|
||||
pnpm run build --filter trigger.dev
|
||||
# Make it accessible to `pnpm exec`
|
||||
pnpm i
|
||||
pnpm exec trigger-cli init
|
||||
```
|
||||
|
||||
6. If you are just testing the `init` command, you can stop here. If you'd like to test the `dev` command, first start the Next.js app on port 3000:
|
||||
5. Change into the `<root>/references/v3-catalog` directory and authorize the CLI to the local server:
|
||||
|
||||
```sh
|
||||
pnpm run dev
|
||||
cd references/v3-catalog
|
||||
cp .env.example .env
|
||||
pnpm exec triggerdev login -a http://localhost:3030
|
||||
```
|
||||
|
||||
7. Open a new terminal window, and then run the `dev` command like so:
|
||||
This will open a new browser window and authorize the CLI against your local user account.
|
||||
|
||||
You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development:
|
||||
|
||||
```sh
|
||||
pnpm exec trigger-cli dev
|
||||
cd references/v3-catalog
|
||||
pnpm exec triggerdev login -a http://localhost:3030 --profile local
|
||||
# later when you run the dev or deploy command:
|
||||
pnpm exec triggerdev dev --profile local
|
||||
pnpm exec triggerdev deploy --profile local
|
||||
```
|
||||
|
||||
8. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
|
||||
### Running
|
||||
|
||||
## Running end-to-end webapp tests
|
||||
The following steps should be followed any time you start working on a new feature you want to test in v3:
|
||||
|
||||
1. Make sure the webapp is running on localhost:3030
|
||||
|
||||
2. Open a terminal window and build the CLI and watch for changes
|
||||
|
||||
```sh
|
||||
pnpm run dev --filter trigger.dev
|
||||
```
|
||||
|
||||
2. Open a new terminal window, and anytime changes are made to the `@trigger.dev/core` package, you'll need to manually rebuild the CLI:
|
||||
|
||||
```sh
|
||||
pnpm run build --filter trigger.dev
|
||||
```
|
||||
|
||||
Note: You do not need to do the same for `@trigger.dev/sdk`, just core.
|
||||
|
||||
3. Open another terminal window, and change into the `<root>/references/v3-catalog` directory.
|
||||
|
||||
4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution:
|
||||
|
||||
```sh
|
||||
# in <root>/references/v3-catalog
|
||||
pnpm exec triggerdev dev
|
||||
```
|
||||
|
||||
If you want additional debug logging, you can use the `--log-level debug` flag:
|
||||
|
||||
```sh
|
||||
# in <root>/references/v3-catalog
|
||||
pnpm exec triggerdev dev --log-level debug
|
||||
```
|
||||
|
||||
5. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside of the `v3-catalog/src/trigger` dir will automatically be rebuilt by the `dev` command.
|
||||
|
||||
6. Navigate to the `v3-catalog` project in your local dashboard at localhost:3030 and you should see the list of tasks.
|
||||
|
||||
7. Go to the "Test" page in the sidebar and select a task. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the `/references/v3-catalog/src/trigger` folder. Many of them accept an empty payload.
|
||||
|
||||
8. Feel free to add additional files in `v3-catalog/src/trigger` to test out specific aspects of the system, or add in edge cases.
|
||||
|
||||
## Running end-to-end webapp tests (deprecated)
|
||||
|
||||
To run the end-to-end tests, follow the steps below:
|
||||
|
||||
@@ -223,6 +221,26 @@ The end-to-end tests use a `setup` and `teardown` script to seed the database wi
|
||||
pnpm run db:studio
|
||||
```
|
||||
|
||||
## Adding and running migrations
|
||||
|
||||
1. Modify packages/database/prisma/schema.prisma file
|
||||
2. Change directory to the packages/database folder
|
||||
|
||||
```sh
|
||||
cd packages/database
|
||||
```
|
||||
|
||||
3. Create and apply the migrations
|
||||
|
||||
```
|
||||
pnpm run db:migrate:dev
|
||||
```
|
||||
|
||||
This creates a migration file and executes the migrations against your database and applies changes to the database schema(s)
|
||||
|
||||
4. Commit generated migrations as well as changes to the schema.prisma file
|
||||
5. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
|
||||
|
||||
## Add sample jobs
|
||||
|
||||
The [references/job-catalog](./references/job-catalog/) project defines simple jobs you can get started with.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
### The open source background jobs framework
|
||||
### The open source background jobs platform
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
# About Trigger.dev
|
||||
> The Trigger.dev v3 developer preview is now open. For more information and to get early access, check out our [developer preview launch post](https://trigger.dev/blog/v3-developer-preview-launch/).
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
|
||||
|
||||
@@ -48,30 +50,6 @@ View every Task in every Run so you can tell exactly what happened.
|
||||
|
||||
Easily integrate with hundreds of third-party APIs – including your own. Use API keys (which never leave your server) or let us handle OAuth for you. Install our integration packages and easily subscribe to webhooks and perform common tasks, or you can easily use your existing favorite Node.JS SDKs and get resumability and idempotency through our `runTask` function.
|
||||
|
||||
## Our progress
|
||||
|
||||
We’re building the most comprehensive and easy-to-use background jobs framework for developers.
|
||||
|
||||
Click the links to join the discussions about our upcoming features.
|
||||
|
||||
| Feature | What it does | Status |
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------------- | ------ |
|
||||
| Integration kit | Official Trigger.dev integrations or build your own | ✅ |
|
||||
| Self-hosting | Host the platform yourself | ✅ |
|
||||
| Cloud | Just write code, no deployment required | ✅ |
|
||||
| Dashboard | View every Task in every Run | ✅ |
|
||||
| Serverless | Long-running Jobs on your serverless backend | ✅ |
|
||||
| React hooks | Easily update your UI with Job progress | ✅ |
|
||||
| React frameworks | Support for Remix, Astro, RedwoodJS & more | ✅ |
|
||||
| [Background tasks](https://github.com/triggerdotdev/trigger.dev/discussions/400) | Offload long or intense Tasks to our infrastructure | 🛠️ |
|
||||
| [Long-running servers](https://github.com/triggerdotdev/trigger.dev/discussions/430) | Run Jobs on your long-running backend | 🛠️ |
|
||||
| Polling Triggers | Subscribe to changes without webhooks | 🕝 |
|
||||
| Vercel integration | Easy deploy and preview environment support | 🕝 |
|
||||
| Streaming | Receive data from your Jobs in realtime | 🕝 |
|
||||
| 100+ integrations | Comprehensive support for popular APIs | 🕝 |
|
||||
| [Trigger.dev Connect](https://github.com/triggerdotdev/trigger.dev/discussions/441) | Use integrations signed in as your users | 🕝 |
|
||||
| File IO | Create Tasks that have file outputs | 🕝 |
|
||||
|
||||
# Getting started
|
||||
|
||||
Visit our docs [here](https://trigger.dev/docs).
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18-bullseye-slim@sha256:a4edd54dcfdcacc8a4100fee71498e8671d99556a1acf5614539214a70092426 AS node-18
|
||||
FROM node:20-bookworm-slim@sha256:72f2f046a5f8468db28730b990b37de63ce93fd1a72a40f531d6aa82afdf0d46 AS node-20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18 AS pruner
|
||||
FROM node-20 AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=coordinator --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18 AS base
|
||||
FROM node-20 AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init docker.io \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
type ExponentialBackoffType = "NoJitter" | "FullJitter" | "EqualJitter";
|
||||
|
||||
type ExponentialBackoffOptions = {
|
||||
base: number;
|
||||
factor: number;
|
||||
min: number;
|
||||
max: number;
|
||||
maxRetries: number;
|
||||
maxElapsed: number;
|
||||
};
|
||||
|
||||
class StopRetrying extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = "StopRetrying";
|
||||
}
|
||||
}
|
||||
|
||||
export class ExponentialBackoff {
|
||||
#retries: number = 0;
|
||||
|
||||
#type: ExponentialBackoffType;
|
||||
#base: number;
|
||||
#factor: number;
|
||||
|
||||
#min: number;
|
||||
#max: number;
|
||||
|
||||
#maxRetries: number;
|
||||
#maxElapsed: number;
|
||||
|
||||
constructor(type?: ExponentialBackoffType, opts: Partial<ExponentialBackoffOptions> = {}) {
|
||||
this.#type = type ?? "NoJitter";
|
||||
this.#base = opts.base ?? 2;
|
||||
this.#factor = opts.factor ?? 1;
|
||||
|
||||
this.#min = opts.min ?? -Infinity;
|
||||
this.#max = opts.max ?? Infinity;
|
||||
|
||||
this.#maxRetries = opts.maxRetries ?? Infinity;
|
||||
this.#maxElapsed = opts.maxElapsed ?? Infinity;
|
||||
}
|
||||
|
||||
#clone() {
|
||||
return new ExponentialBackoff(this.#type, {
|
||||
base: this.#base,
|
||||
factor: this.#factor,
|
||||
min: this.#min,
|
||||
max: this.#max,
|
||||
maxRetries: this.#maxRetries,
|
||||
maxElapsed: this.#maxElapsed,
|
||||
});
|
||||
}
|
||||
|
||||
type(type?: ExponentialBackoffType) {
|
||||
if (typeof type !== "undefined") {
|
||||
this.#type = type;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
base(base?: number) {
|
||||
if (typeof base !== "undefined") {
|
||||
this.#base = base;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
factor(factor?: number) {
|
||||
if (typeof factor !== "undefined") {
|
||||
this.#factor = factor;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
min(min?: number) {
|
||||
if (typeof min !== "undefined") {
|
||||
this.#min = min;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
max(max?: number) {
|
||||
if (typeof max !== "undefined") {
|
||||
this.#max = max;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
maxRetries(maxRetries?: number) {
|
||||
if (typeof maxRetries !== "undefined") {
|
||||
this.#maxRetries = maxRetries;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
maxElapsed(maxElapsed?: number) {
|
||||
if (typeof maxElapsed !== "undefined") {
|
||||
this.#maxElapsed = maxElapsed;
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
retries(retries?: number) {
|
||||
if (typeof retries !== "undefined") {
|
||||
if (retries > this.#maxRetries) {
|
||||
console.error(
|
||||
`Can't set retries ${retries} higher than maxRetries (${
|
||||
this.#maxRetries
|
||||
}), setting to maxRetries instead.`
|
||||
);
|
||||
this.#retries = this.#maxRetries;
|
||||
} else {
|
||||
this.#retries = retries;
|
||||
}
|
||||
}
|
||||
return this.#clone();
|
||||
}
|
||||
|
||||
async *retryAsync(maxRetries: number = this.#maxRetries ?? Infinity) {
|
||||
let elapsed = 0;
|
||||
let retry = 0;
|
||||
|
||||
while (retry <= maxRetries) {
|
||||
const delay = this.delay(retry);
|
||||
elapsed += delay;
|
||||
|
||||
if (elapsed > this.#maxElapsed) {
|
||||
break;
|
||||
}
|
||||
|
||||
yield {
|
||||
delay: {
|
||||
seconds: delay,
|
||||
milliseconds: delay * 1000,
|
||||
},
|
||||
retry,
|
||||
};
|
||||
|
||||
retry++;
|
||||
}
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* this.retryAsync();
|
||||
}
|
||||
|
||||
delay(retries: number = this.#retries, jitter: boolean = true) {
|
||||
if (retries > this.#maxRetries) {
|
||||
console.error(
|
||||
`Can't set retries ${retries} higher than maxRetries (${
|
||||
this.#maxRetries
|
||||
}), setting to maxRetries instead.`
|
||||
);
|
||||
retries = this.#maxRetries;
|
||||
}
|
||||
|
||||
let delay = this.#factor * this.#base ** retries;
|
||||
|
||||
switch (this.#type) {
|
||||
case "NoJitter": {
|
||||
break;
|
||||
}
|
||||
case "FullJitter": {
|
||||
if (!jitter) {
|
||||
delay = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
delay *= Math.random();
|
||||
break;
|
||||
}
|
||||
case "EqualJitter": {
|
||||
if (!jitter) {
|
||||
delay *= 0.5;
|
||||
break;
|
||||
}
|
||||
|
||||
delay *= 0.5 * (1 + Math.random());
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown backoff type: ${this.#type}`);
|
||||
}
|
||||
}
|
||||
|
||||
delay = Math.min(delay, this.#max);
|
||||
delay = Math.max(delay, this.#min);
|
||||
delay = Math.round(delay);
|
||||
|
||||
return delay;
|
||||
}
|
||||
|
||||
elapsed(retries: number = this.#retries, jitter: boolean = true) {
|
||||
let elapsed = 0;
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
elapsed += this.delay(i, jitter);
|
||||
}
|
||||
|
||||
const total = elapsed;
|
||||
|
||||
let days = 0;
|
||||
if (elapsed > 3600 * 24) {
|
||||
days = Math.floor(elapsed / 3600 / 24);
|
||||
elapsed -= days * 3600 * 24;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
if (elapsed > 3600) {
|
||||
hours = Math.floor(elapsed / 3600);
|
||||
elapsed -= hours * 3600;
|
||||
}
|
||||
|
||||
let minutes = 0;
|
||||
if (elapsed > 60) {
|
||||
minutes = Math.floor(elapsed / 60);
|
||||
elapsed -= minutes * 60;
|
||||
}
|
||||
|
||||
const seconds = elapsed;
|
||||
|
||||
return {
|
||||
seconds,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#retries = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
next() {
|
||||
this.#retries++;
|
||||
return this.delay();
|
||||
}
|
||||
|
||||
stop() {
|
||||
throw new StopRetrying();
|
||||
}
|
||||
|
||||
static StopRetrying = StopRetrying;
|
||||
}
|
||||
+563
-134
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ PLATFORM_WS_PORT=3030
|
||||
PLATFORM_SECRET=provider-secret
|
||||
SECURE_CONNECTION=false
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:3030/otel
|
||||
|
||||
# Use this if you are on macOS
|
||||
# COORDINATOR_HOST="host.docker.internal"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318"
|
||||
@@ -1,16 +1,47 @@
|
||||
# syntax=docker/dockerfile:labs
|
||||
|
||||
FROM node:18-slim AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y dumb-init
|
||||
|
||||
FROM base
|
||||
FROM node:20-alpine@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 AS node-20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node dist/index.mjs /app/
|
||||
FROM node-20-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=docker-provider --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache dumb-init docker
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
COPY --from=pruner --chown=node:node /app/out/json/ .
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
|
||||
FROM base AS dev-deps
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV development
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --no-frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
RUN corepack enable
|
||||
|
||||
COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter docker-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
RUN corepack enable
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=builder --chown=node:node /app/apps/docker-provider/dist/index.mjs ./index.mjs
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "/app/index.mjs" ]
|
||||
USER node
|
||||
|
||||
CMD [ "/usr/bin/dumb-init", "--", "/usr/local/bin/node", "./index.mjs" ]
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
TaskOperationsRestoreOptions,
|
||||
TaskOperationsCreateOptions,
|
||||
TaskOperationsIndexOptions,
|
||||
isExecaChildProcess,
|
||||
testDockerCheckpoint,
|
||||
} from "@trigger.dev/core-apps";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
|
||||
@@ -13,75 +15,68 @@ import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
|
||||
const MACHINE_NAME = process.env.MACHINE_NAME || "local";
|
||||
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
|
||||
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
|
||||
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
|
||||
|
||||
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
|
||||
);
|
||||
|
||||
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
|
||||
|
||||
type InitializeReturn = {
|
||||
type TaskOperationsInitReturn = {
|
||||
canCheckpoint: boolean;
|
||||
willSimulate: boolean;
|
||||
};
|
||||
|
||||
function isExecaChildProcess(maybeExeca: unknown): maybeExeca is Awaited<ExecaChildProcess> {
|
||||
return typeof maybeExeca === "object" && maybeExeca !== null && "escapedCommand" in maybeExeca;
|
||||
}
|
||||
|
||||
class DockerTaskOperations implements TaskOperations {
|
||||
#initialized = false;
|
||||
#canCheckpoint = false;
|
||||
|
||||
constructor(private opts = { forceSimulate: false }) {}
|
||||
|
||||
async #initialize(): Promise<InitializeReturn> {
|
||||
async init(): Promise<TaskOperationsInitReturn> {
|
||||
if (this.#initialized) {
|
||||
return this.#getInitializeReturn();
|
||||
return this.#getInitReturn(this.#canCheckpoint);
|
||||
}
|
||||
|
||||
logger.log("Initializing task operations");
|
||||
|
||||
if (this.opts.forceSimulate) {
|
||||
logger.log("Forced simulation enabled. Will simulate regardless of checkpoint support.");
|
||||
const testCheckpoint = await testDockerCheckpoint();
|
||||
|
||||
if (testCheckpoint.ok) {
|
||||
return this.#getInitReturn(true);
|
||||
}
|
||||
|
||||
try {
|
||||
await $`criu --version`;
|
||||
} catch (error) {
|
||||
logger.error("No checkpoint support: Missing CRIU binary. Will simulate instead.");
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
}
|
||||
|
||||
try {
|
||||
await $`docker checkpoint`;
|
||||
} catch (error) {
|
||||
logger.error("No checkpoint support: Docker needs to have experimental features enabled");
|
||||
logger.error("Will simulate instead");
|
||||
this.#canCheckpoint = false;
|
||||
this.#initialized = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
}
|
||||
|
||||
logger.log("Full checkpoint support!");
|
||||
|
||||
this.#initialized = true;
|
||||
this.#canCheckpoint = true;
|
||||
|
||||
return this.#getInitializeReturn();
|
||||
logger.error(testCheckpoint.message, testCheckpoint.error);
|
||||
return this.#getInitReturn(false);
|
||||
}
|
||||
|
||||
#getInitializeReturn(): InitializeReturn {
|
||||
#getInitReturn(canCheckpoint: boolean): TaskOperationsInitReturn {
|
||||
this.#initialized = true;
|
||||
this.#canCheckpoint = canCheckpoint;
|
||||
|
||||
if (canCheckpoint) {
|
||||
logger.log("Full checkpoint support!");
|
||||
}
|
||||
|
||||
const willSimulate = !canCheckpoint || this.opts.forceSimulate;
|
||||
|
||||
if (willSimulate) {
|
||||
logger.log("Simulation mode enabled. Containers will be paused, not checkpointed.", {
|
||||
forceSimulate: this.opts.forceSimulate,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
canCheckpoint: this.#canCheckpoint,
|
||||
willSimulate: !this.#canCheckpoint || this.opts.forceSimulate,
|
||||
canCheckpoint,
|
||||
willSimulate,
|
||||
};
|
||||
}
|
||||
|
||||
async index(opts: TaskOperationsIndexOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getIndexContainerName(opts.shortCode);
|
||||
|
||||
@@ -90,41 +85,27 @@ class DockerTaskOperations implements TaskOperations {
|
||||
port: COORDINATOR_PORT,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
await execa("docker", [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--rm",
|
||||
`--env=INDEX_TASKS=true`,
|
||||
`--env=TRIGGER_SECRET_KEY=${opts.apiKey}`,
|
||||
`--env=TRIGGER_API_URL=${opts.apiUrl}`,
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
`${opts.imageRef}`,
|
||||
])
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (!isExecaChildProcess(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error("Index failed:", {
|
||||
opts,
|
||||
exitCode: error.exitCode,
|
||||
escapedCommand: error.escapedCommand,
|
||||
stdout: error.stdout,
|
||||
stderr: error.stderr,
|
||||
});
|
||||
}
|
||||
logger.debug(
|
||||
await execa("docker", [
|
||||
"run",
|
||||
"--network=host",
|
||||
"--rm",
|
||||
`--env=INDEX_TASKS=true`,
|
||||
`--env=TRIGGER_SECRET_KEY=${opts.apiKey}`,
|
||||
`--env=TRIGGER_API_URL=${opts.apiUrl}`,
|
||||
`--env=TRIGGER_ENV_ID=${opts.envId}`,
|
||||
`--env=OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT}`,
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--name=${containerName}`,
|
||||
`${opts.imageRef}`,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async create(opts: TaskOperationsCreateOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
|
||||
@@ -160,7 +141,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async restore(opts: TaskOperationsRestoreOptions) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
|
||||
@@ -189,7 +170,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async delete(opts: { runId: string }) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
const containerName = this.#getRunContainerName(opts.runId);
|
||||
await this.#sendPreStop(containerName);
|
||||
@@ -198,7 +179,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async get(opts: { runId: string }) {
|
||||
await this.#initialize();
|
||||
await this.init();
|
||||
|
||||
logger.log("noop: get");
|
||||
}
|
||||
@@ -278,7 +259,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new DockerTaskOperations({ forceSimulate: true }),
|
||||
tasks: new DockerTaskOperations({ forceSimulate: FORCE_CHECKPOINT_SIMULATION }),
|
||||
type: "docker",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
FROM node:18-alpine@sha256:ca9f6cb0466f9638e59e0c249d335a07c867cd50c429b5c7830dda1bed584649 AS node-18-alpine
|
||||
FROM node:20-alpine@sha256:7a91aa397f2e2dfbfcdad2e2d72599f374e0b0172be1d86eeb73f1d33f36a4b2 AS node-20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM node-18-alpine AS pruner
|
||||
FROM node-20-alpine AS pruner
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npx -q turbo@1.10.9 prune --scope=kubernetes-provider --docker
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
|
||||
FROM node-18-alpine AS base
|
||||
FROM node-20-alpine AS base
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
@@ -7,14 +7,21 @@ import {
|
||||
TaskOperationsIndexOptions,
|
||||
TaskOperationsRestoreOptions,
|
||||
} from "@trigger.dev/core-apps";
|
||||
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
MachinePreset,
|
||||
PostStartCauses,
|
||||
PreStopCauses,
|
||||
EnvironmentType,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { randomUUID } from "crypto";
|
||||
import { TaskMonitor } from "./taskMonitor";
|
||||
import { PodCleaner } from "./podCleaner";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "local";
|
||||
const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318";
|
||||
const POD_CLEANER_INTERVAL_SECONDS = Number(process.env.POD_CLEANER_INTERVAL_SECONDS || "300");
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
logger.log(`running in ${RUNTIME_ENV} mode`);
|
||||
@@ -46,6 +53,10 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
this.#k8sApi = this.#createK8sApi();
|
||||
}
|
||||
|
||||
async init() {
|
||||
// noop
|
||||
}
|
||||
|
||||
async index(opts: TaskOperationsIndexOptions) {
|
||||
await this.#createJob(
|
||||
{
|
||||
@@ -211,7 +222,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
},
|
||||
{
|
||||
name: "populate-taskinfo",
|
||||
image: "docker.io/library/busybox",
|
||||
image: "registry.digitalocean.com/trigger/busybox",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-c"],
|
||||
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
|
||||
@@ -315,6 +326,9 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
{
|
||||
name: "registry-trigger",
|
||||
},
|
||||
{
|
||||
name: "registry-trigger-failover",
|
||||
},
|
||||
],
|
||||
nodeSelector: {
|
||||
nodetype: "worker",
|
||||
@@ -390,10 +404,10 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
};
|
||||
}
|
||||
|
||||
#getResourcesFromMachineConfig(config: Machine): ComputeResources {
|
||||
#getResourcesFromMachineConfig(preset: MachinePreset): ComputeResources {
|
||||
return {
|
||||
cpu: `${config.cpu}`,
|
||||
memory: `${config.memory}G`,
|
||||
cpu: `${preset.cpu}`,
|
||||
memory: `${preset.memory}G`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -543,3 +557,11 @@ const taskMonitor = new TaskMonitor({
|
||||
});
|
||||
|
||||
taskMonitor.start();
|
||||
|
||||
const podCleaner = new PodCleaner({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: "default",
|
||||
intervalInSeconds: POD_CLEANER_INTERVAL_SECONDS,
|
||||
});
|
||||
|
||||
podCleaner.start();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps";
|
||||
|
||||
type PodCleanerOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
namespace?: string;
|
||||
intervalInSeconds?: number;
|
||||
};
|
||||
|
||||
export class PodCleaner {
|
||||
private enabled = false;
|
||||
private namespace = "default";
|
||||
private intervalInSeconds = 300;
|
||||
|
||||
private logger = new SimpleLogger("[PodCleaner]");
|
||||
private k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
};
|
||||
|
||||
constructor(private opts: PodCleanerOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
if (opts.intervalInSeconds) {
|
||||
this.intervalInSeconds = opts.intervalInSeconds;
|
||||
}
|
||||
|
||||
this.k8sClient = this.#createK8sClient();
|
||||
}
|
||||
|
||||
#createK8sClient() {
|
||||
const kubeConfig = new k8s.KubeConfig();
|
||||
|
||||
if (this.opts.runtimeEnv === "local") {
|
||||
kubeConfig.loadFromDefault();
|
||||
} else if (this.opts.runtimeEnv === "kubernetes") {
|
||||
kubeConfig.loadFromCluster();
|
||||
} else {
|
||||
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
|
||||
}
|
||||
|
||||
return {
|
||||
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
|
||||
kubeConfig: kubeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
|
||||
if (typeof candidate !== "object" || candidate === null) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#logK8sError(err: unknown, debugOnly = false) {
|
||||
if (debugOnly) {
|
||||
this.logger.debug("K8s API Error", err);
|
||||
} else {
|
||||
this.logger.error("K8s API Error", err);
|
||||
}
|
||||
}
|
||||
|
||||
#handleK8sError(err: unknown) {
|
||||
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
|
||||
this.#logK8sError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError(err, true);
|
||||
|
||||
if (typeof err.body.message === "string") {
|
||||
this.#logK8sError({ message: err.body.message });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError({ body: err.body });
|
||||
}
|
||||
|
||||
async #deletePods(opts: {
|
||||
namespace: string;
|
||||
dryRun?: boolean;
|
||||
fieldSelector?: string;
|
||||
labelSelector?: string;
|
||||
}) {
|
||||
return await this.k8sClient.core
|
||||
.deleteCollectionNamespacedPod(
|
||||
opts.namespace,
|
||||
undefined, // pretty
|
||||
undefined, // continue
|
||||
opts.dryRun ? "All" : undefined,
|
||||
opts.fieldSelector,
|
||||
undefined, // gracePeriodSeconds
|
||||
opts.labelSelector
|
||||
)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
}
|
||||
|
||||
async #deleteCompletedRuns() {
|
||||
this.logger.log("Deleting completed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Succeeded",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteFailedRuns() {
|
||||
this.logger.log("Deleting failed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Failed",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteUnrecoverableRuns() {
|
||||
await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=?",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.enabled = true;
|
||||
this.logger.log("Starting");
|
||||
|
||||
const completedInterval = setInterval(async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(completedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteCompletedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
}, this.intervalInSeconds * 1000);
|
||||
|
||||
const failedInterval = setInterval(
|
||||
async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(failedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteFailedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
},
|
||||
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
|
||||
2 * this.intervalInSeconds * 1000
|
||||
);
|
||||
|
||||
// this.#launchTests();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = false;
|
||||
this.logger.log("Shutting down..");
|
||||
}
|
||||
|
||||
async #launchTests() {
|
||||
const createPod = async (
|
||||
container: k8s.V1Container,
|
||||
name: string,
|
||||
labels?: Record<string, string>
|
||||
) => {
|
||||
this.logger.log("Creating pod:", name);
|
||||
|
||||
const pod = {
|
||||
metadata: {
|
||||
name,
|
||||
labels,
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
terminationGracePeriodSeconds: 1,
|
||||
containers: [container],
|
||||
},
|
||||
} satisfies k8s.V1Pod;
|
||||
|
||||
await this.k8sClient.core
|
||||
.createNamespacedPod(this.namespace, pod)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
};
|
||||
|
||||
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "sleep infinity"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "true"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "false"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
await createIdlePod("test-idle-1", { app: "task-run" });
|
||||
await createFailedPod("test-failed-1", { app: "task-run" });
|
||||
await createCompletedPod("test-completed-1", { app: "task-run" });
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
|
||||
|
||||
export class TaskMonitor {
|
||||
#enabled = false;
|
||||
|
||||
#logger = new SimpleLogger("[TaskMonitor]");
|
||||
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
|
||||
#processedPods = new Map<string, number>();
|
||||
#queue = new PQueue({ concurrency: 10 });
|
||||
|
||||
#k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
@@ -44,6 +46,10 @@ export class TaskMonitor {
|
||||
private labelSelector = "app in (task-index, task-run)";
|
||||
|
||||
constructor(private opts: TaskMonitorOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
this.#k8sClient = this.#createK8sClient();
|
||||
|
||||
this.#taskInformer = this.#createTaskInformer();
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"dev": "wrangler dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20230419.0",
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"typescript": "^5.0.4",
|
||||
"wrangler": "^3.0.0"
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { queueEvent } from "./events/queueEvent";
|
||||
import { queueEvents } from "./events/queueEvents";
|
||||
import { applyRateLimit } from "./rateLimit";
|
||||
import { Ratelimit } from "./rateLimiter";
|
||||
|
||||
export interface Env {
|
||||
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
|
||||
@@ -9,6 +11,8 @@ export interface Env {
|
||||
AWS_SQS_SECRET_ACCESS_KEY: string;
|
||||
AWS_SQS_QUEUE_URL: string;
|
||||
AWS_SQS_REGION: string;
|
||||
//rate limiter
|
||||
API_RATE_LIMITER: Ratelimit;
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -25,13 +29,13 @@ export default {
|
||||
switch (url.pathname) {
|
||||
case "/api/v1/events": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvent(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvent(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/v1/events/bulk": {
|
||||
if (request.method === "POST") {
|
||||
return queueEvents(request, env);
|
||||
return applyRateLimit(request, env, () => queueEvents(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Env } from "src";
|
||||
import { getApiKeyFromRequest } from "./apikey";
|
||||
import { json } from "./json";
|
||||
|
||||
export async function applyRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
fn: () => Promise<Response>
|
||||
): Promise<Response> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
if (apiKey) {
|
||||
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
|
||||
const { success } = result;
|
||||
console.log(`Rate limiter`, {
|
||||
success,
|
||||
key: `${apiKey.apiKey.substring(0, 12)}...`,
|
||||
});
|
||||
if (!success) {
|
||||
//60s in the future
|
||||
const reset = Date.now() + 60 * 1000;
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
|
||||
return json(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"x-ratelimit-reset": reset.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Rate limiter: no API key for request`);
|
||||
}
|
||||
|
||||
//call the original function
|
||||
return fn();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Ratelimit {
|
||||
/*
|
||||
* The ratelimit function
|
||||
* @param {RatelimitOptions} options
|
||||
* @returns {Promise<RatelimitResponse>}
|
||||
*/
|
||||
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
|
||||
}
|
||||
|
||||
export interface RatelimitOptions {
|
||||
/*
|
||||
* The key to identify the user, can be an IP address, user ID, etc.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RatelimitResponse {
|
||||
/*
|
||||
* The ratelimit success status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
success: boolean;
|
||||
}
|
||||
@@ -1,7 +1,33 @@
|
||||
name = "proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-10-30"
|
||||
compatibility_date = "2024-05-13"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
[env.staging]
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.staging.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "1"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 100, period = 60 }
|
||||
|
||||
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.prod.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "2"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 300, period = 60 }
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.861435" y="0.861435" width="30.2771" height="30.2771" rx="15.1386" stroke="#D7D9DD" stroke-width="1.72287"/>
|
||||
<path d="M14.9669 12.642L11.6417 21.7732H9.8155L6.49036 12.642H8.04094L10.7286 20.2571L13.4163 12.642H14.9669ZM20.534 14.5544C21.6884 14.5716 23.0666 14.0375 23.0666 12.4525C23.0666 11.3843 22.1018 10.7124 20.534 10.7124C19.2591 10.7124 18.3632 11.3326 18.2082 12.3146L16.6576 12.2113C16.8815 10.4884 18.4838 9.26516 20.534 9.26516C22.946 9.26516 24.6345 10.5745 24.6345 12.4525C24.6345 13.8135 23.8592 14.7267 22.3775 15.1574C24.0831 15.657 25.0479 16.8286 25.0479 18.4136C25.0479 20.55 23.1872 22.0489 20.534 22.0489C18.0876 22.0489 16.313 20.6361 16.2096 18.6203L17.743 18.517C17.8808 20.0159 19.3108 20.6016 20.534 20.6016C21.9812 20.6016 23.4974 19.947 23.4974 18.293C23.4974 16.6563 21.9812 15.9499 20.534 15.9844L19.5865 16.0016V14.5371L20.534 14.5544Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -1,10 +1,14 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon, GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import { ActivityIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
import { Button, LinkButton } from "./primitives/Buttons";
|
||||
import { Fieldset } from "./primitives/Fieldset";
|
||||
import { FormButtons } from "./primitives/FormButtons";
|
||||
@@ -13,20 +17,9 @@ import { Header1, Header2 } from "./primitives/Headers";
|
||||
import { InputGroup } from "./primitives/InputGroup";
|
||||
import { Label } from "./primitives/Label";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./primitives/Select";
|
||||
import { Select, SelectItem } from "./primitives/Select";
|
||||
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ActivityIcon, HeartPulseIcon } from "lucide-react";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -78,20 +71,20 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup className="max-w-full">
|
||||
<SelectGroup>
|
||||
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
|
||||
<SelectTrigger size="medium" width="full">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(feedbackTypeLabel).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Select
|
||||
{...conform.select(feedbackType)}
|
||||
variant="tertiary/medium"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="Select type"
|
||||
text={(value) => feedbackTypeLabel[value]}
|
||||
dropdownIcon
|
||||
>
|
||||
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
@@ -122,14 +115,7 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
Docs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/introduction")}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
v3 Docs (Developer preview)
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={"https://trigger.openstatus.dev/"}
|
||||
to={"https://status.trigger.dev/"}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ActivityIcon}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./primitives/ClientTabs";
|
||||
import { ClipboardField } from "./primitives/ClipboardField";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
|
||||
export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
|
||||
return (
|
||||
@@ -133,9 +134,38 @@ export function TriggerDevStep({ extra }: { extra?: string }) {
|
||||
// Trigger.dev version 3 setup commands
|
||||
const v3PackageTag = "beta";
|
||||
|
||||
function getApiUrlArg() {
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
let apiUrl: string | undefined = undefined;
|
||||
|
||||
switch (appOrigin) {
|
||||
case "https://cloud.trigger.dev":
|
||||
// don't display the arg, use the CLI default
|
||||
break;
|
||||
case "https://test-cloud.trigger.dev":
|
||||
apiUrl = "https://test-api.trigger.dev";
|
||||
break;
|
||||
case "https://internal.trigger.dev":
|
||||
apiUrl = "https://internal-api.trigger.dev";
|
||||
break;
|
||||
default:
|
||||
apiUrl = appOrigin;
|
||||
break;
|
||||
}
|
||||
|
||||
return apiUrl ? `-a ${apiUrl}` : undefined;
|
||||
}
|
||||
|
||||
export function InitCommandV3() {
|
||||
const project = useProject();
|
||||
const projectRef = project.ref;
|
||||
|
||||
const apiUrlArg = getApiUrlArg();
|
||||
|
||||
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
|
||||
const initCommand = initCommandParts.filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
@@ -148,7 +178,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`npx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
@@ -156,7 +186,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`pnpm dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
@@ -164,7 +194,7 @@ export function InitCommandV3() {
|
||||
variant="primary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx trigger.dev@${v3PackageTag} init -p ${projectRef}`}
|
||||
value={`yarn dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
@@ -20,9 +20,11 @@ export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ShieldCheckIcon className="h-5 w-5" />
|
||||
<ShieldCheckIcon className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">{children}</TooltipContent>
|
||||
<TooltipContent className="flex max-h-[90vh] items-center gap-1 overflow-y-auto">
|
||||
{children}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -290,6 +290,7 @@ export function TierPro({
|
||||
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
|
||||
fullWidth
|
||||
value={concurrentBracketCode}
|
||||
variant="primary"
|
||||
onChange={(v) => setConcurrentBracketCode(v)}
|
||||
/>
|
||||
<div className="py-6">
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import Highlight, { defaultProps } from "prism-react-renderer";
|
||||
import { Highlight, Prism } from "prism-react-renderer";
|
||||
import { forwardRef, useCallback, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
|
||||
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
|
||||
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
|
||||
|
||||
async function setup() {
|
||||
(typeof global !== "undefined" ? global : window).Prism = Prism;
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
}
|
||||
setup();
|
||||
|
||||
type CodeBlockProps = {
|
||||
/** Code which will be highlighted */
|
||||
code: string;
|
||||
@@ -238,7 +247,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
)}
|
||||
|
||||
{shouldHighlight ? (
|
||||
<Highlight {...defaultProps} theme={theme} code={code} language={language}>
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
@@ -283,7 +292,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineProps.key}
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
@@ -312,7 +321,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={tokenProps.key}
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
const variants = {
|
||||
@@ -7,6 +9,30 @@ const variants = {
|
||||
large: "h-6 text-xs px-1.5 rounded",
|
||||
};
|
||||
|
||||
export function EnvironmentTypeLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName(environment),
|
||||
environmentTextClassName(environment),
|
||||
variants[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{environmentTypeTitle(environment)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
@@ -33,6 +59,74 @@ export function EnvironmentLabel({
|
||||
);
|
||||
}
|
||||
|
||||
type EnvironmentWithUsername = Environment & { userName?: string };
|
||||
|
||||
export function EnvironmentLabels({
|
||||
environments,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environments: EnvironmentWithUsername[];
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
const devEnvironments = sortEnvironments(
|
||||
environments.filter((env) => env.type === "DEVELOPMENT")
|
||||
);
|
||||
const firstDevEnvironment = devEnvironments[0];
|
||||
const otherDevEnvironments = devEnvironments.slice(1);
|
||||
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-2", className)}>
|
||||
{firstDevEnvironment && (
|
||||
<EnvironmentLabel
|
||||
environment={firstDevEnvironment}
|
||||
userName={firstDevEnvironment.userName}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{otherDevEnvironments.length > 0 ? (
|
||||
<SimpleTooltip
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName({ type: "DEVELOPMENT" }),
|
||||
environmentTextClassName({ type: "DEVELOPMENT" }),
|
||||
variants[size]
|
||||
)}
|
||||
>
|
||||
+{otherDevEnvironments.length}
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<div className="flex gap-1 py-1">
|
||||
{otherDevEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{otherEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
@@ -46,6 +140,19 @@ export function environmentTitle(environment: Environment, username?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTypeTitle(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Dev";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentColorClassName(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
} from "../primitives/SimpleSelect";
|
||||
import { EventListSearchSchema } from "./EventStatuses";
|
||||
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
|
||||
import { cn } from "~/utils/cn";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Checkbox } from "../primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormError } from "../primitives/FormError";
|
||||
import { Header2, Header3 } from "../primitives/Headers";
|
||||
@@ -123,7 +123,7 @@ export function ConnectToOAuthForm({
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
To use your own OAuth app, check the option below and insert the details.
|
||||
</Paragraph>
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
@@ -200,7 +200,7 @@ export function ConnectToOAuthForm({
|
||||
)}
|
||||
{authMethod.scopes.map((s) => {
|
||||
return (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={s.name}
|
||||
id={s.name}
|
||||
value={s.name}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ApiAuthenticationMethodOAuth2, Integration, Scope } from "~/services/ex
|
||||
import { cn } from "~/utils/cn";
|
||||
import { CodeBlock } from "../code/CodeBlock";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Checkbox } from "../primitives/Checkbox";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormError } from "../primitives/FormError";
|
||||
import { Header2, Header3 } from "../primitives/Headers";
|
||||
@@ -113,7 +113,7 @@ export function UpdateOAuthForm({
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
To use your own OAuth app, check the option below and insert the details.
|
||||
</Paragraph>
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
@@ -189,7 +189,7 @@ export function UpdateOAuthForm({
|
||||
)}
|
||||
{authMethod.scopes.map((s) => {
|
||||
return (
|
||||
<Checkbox
|
||||
<CheckboxWithLabel
|
||||
key={s.name}
|
||||
id={s.name}
|
||||
value={s.name}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PageBody({
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
: "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CursorArrowRaysIcon,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
v3ApiKeysPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3RunsPath,
|
||||
@@ -231,31 +233,22 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Create a new Slack channel" />
|
||||
<StepNumber stepNumber="1" title="Email us" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
In your Slack app, create a new channel from the main menu by going to File{" "}
|
||||
<ArrowRightIcon className="inline h-4 w-4 text-text-dimmed" /> New Channel
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Setup your channel" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Name your channel, set its visibility and click 'Create'.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Invite Trigger.dev" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Invite this email address to your channel:{" "}
|
||||
Send us an email to this address from your Trigger.dev account email
|
||||
address:
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
value="james@trigger.dev"
|
||||
value="priority-support@trigger.dev"
|
||||
className="my-2"
|
||||
/>
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Look out for an invite from Slack" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
As soon as we can, we'll accept your invitation and say hello!
|
||||
As soon as we can, we'll setup a Slack Connect channel and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
@@ -549,6 +542,8 @@ function V3ProjectSideMenu({
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { alertsEnabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project (v3)"} />
|
||||
@@ -601,6 +596,15 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
"tertiary/small": {
|
||||
box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600",
|
||||
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
|
||||
},
|
||||
"minimal/small": {
|
||||
box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs",
|
||||
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
|
||||
},
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variants;
|
||||
|
||||
type AppliedFilterProps = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppliedFilter({
|
||||
label,
|
||||
value,
|
||||
removable = true,
|
||||
onRemove,
|
||||
variant = "tertiary/small",
|
||||
className,
|
||||
}: AppliedFilterProps) {
|
||||
const variantClassName = variants[variant];
|
||||
return (
|
||||
<div className={cn("flex items-center transition", variantClassName.box, className)}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="text-text-dimmed">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
<div className="text-text-bright">
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
{removable && (
|
||||
<button
|
||||
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,11 +48,11 @@ const theme = {
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
|
||||
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
|
||||
button:
|
||||
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./Badge";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
@@ -53,18 +53,18 @@ export type CheckboxProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"checked" | "onChange"
|
||||
> & {
|
||||
id: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
variant?: keyof typeof variants;
|
||||
label?: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
description?: string;
|
||||
badges?: string[];
|
||||
className?: string;
|
||||
onChange?: (isChecked: boolean) => void;
|
||||
};
|
||||
|
||||
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
@@ -172,3 +172,21 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type SimpleCheckboxProps = Omit<React.ComponentProps<"input">, "type">;
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, SimpleCheckboxProps>(
|
||||
({ className, ...props }: SimpleCheckboxProps, ref) => {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
className={cn(
|
||||
props.readOnly || props.disabled ? "cursor-default" : "cursor-pointer",
|
||||
"read-only:border-charcoal-650 disabled:border-charcoal-650 rounded-sm border border-charcoal-600 bg-transparent transition checked:!bg-indigo-500 read-only:!bg-charcoal-700 group-hover:bg-charcoal-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:!bg-charcoal-700"
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ type DateTimeProps = {
|
||||
timeZone?: string;
|
||||
includeSeconds?: boolean;
|
||||
includeTime?: boolean;
|
||||
showTimezone?: boolean;
|
||||
};
|
||||
|
||||
export const DateTime = ({
|
||||
@@ -13,6 +14,7 @@ export const DateTime = ({
|
||||
timeZone,
|
||||
includeSeconds = true,
|
||||
includeTime = true,
|
||||
showTimezone = false,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
|
||||
@@ -42,7 +44,12 @@ export const DateTime = ({
|
||||
);
|
||||
}, [locales, includeSeconds, realDate]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
return (
|
||||
<Fragment>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatDateTime(
|
||||
|
||||
@@ -49,12 +49,7 @@ export function DetailCell({
|
||||
const variation = variations[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
|
||||
<IconInBox
|
||||
icon={leadingIcon}
|
||||
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
|
||||
@@ -62,20 +57,14 @@ export function DetailCell({
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Paragraph
|
||||
variant={variation.label.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left transition group-hover:text-text-bright",
|
||||
variation.label.className
|
||||
)}
|
||||
className={cn("flex-1 text-left", variation.label.className)}
|
||||
>
|
||||
{label}
|
||||
</Paragraph>
|
||||
{description && (
|
||||
<Paragraph
|
||||
variant={variation.description.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
|
||||
variation.description.className
|
||||
)}
|
||||
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
|
||||
@@ -43,7 +43,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-3.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background-dimmed px-4 pb-4 pt-2.5 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -74,7 +74,7 @@ DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col text-left font-medium text-text-dimmed", className)}
|
||||
className={cn("flex flex-col text-left font-medium text-text-bright", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
base: "bg-charcoal-700",
|
||||
active: "text-text-bright hover:bg-charcoal-750/50",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-charcoal-700/50",
|
||||
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
variant?: Variants;
|
||||
fullWidth?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
@@ -21,11 +35,18 @@ export default function SegmentedControl({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
variant = "secondary",
|
||||
fullWidth,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 rounded text-text-bright",
|
||||
variants[variant].base,
|
||||
fullWidth ? "w-full" : "w-fit"
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={value}
|
||||
defaultValue={defaultValue ?? options[0].value}
|
||||
@@ -46,11 +67,11 @@ export default function SegmentedControl({
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
|
||||
active
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
checked
|
||||
? "text-text-bright"
|
||||
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
|
||||
? variants[variant].active
|
||||
: "text-text-dimmed transition hover:text-text-bright"
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -60,12 +81,12 @@ export default function SegmentedControl({
|
||||
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
|
||||
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
|
||||
</div>
|
||||
{checked && (
|
||||
{checked && variant === "primary" && (
|
||||
<motion.div
|
||||
layoutId={`segmented-control-${name}`}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
|
||||
></motion.div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,143 +1,654 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { SelectProps as AriaSelectProps } from "@ariakit/react";
|
||||
import { SelectValue } from "@ariakit/react-core/select/select-value";
|
||||
import { Link } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { MatchSorterOptions, matchSorter } from "match-sorter";
|
||||
|
||||
const sizes = {
|
||||
"secondary/small":
|
||||
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
|
||||
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
|
||||
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
|
||||
small: {
|
||||
button: "h-6 rounded text-xs px-2 ",
|
||||
},
|
||||
medium: {
|
||||
button: "h-8 rounded text-xs px-3 text-sm",
|
||||
},
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
size?: keyof typeof sizes;
|
||||
width?: "content" | "full";
|
||||
const style = {
|
||||
tertiary: {
|
||||
button:
|
||||
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
|
||||
},
|
||||
minimal: {
|
||||
button:
|
||||
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
|
||||
},
|
||||
};
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
|
||||
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
|
||||
const sizeClassName = sizes[size];
|
||||
const variants = {
|
||||
"tertiary/small": {
|
||||
button: cn(sizes.small.button, style.tertiary.button),
|
||||
},
|
||||
"tertiary/medium": {
|
||||
button: cn(sizes.medium.button, style.tertiary.button),
|
||||
},
|
||||
"minimal/small": {
|
||||
button: cn(sizes.small.button, style.minimal.button),
|
||||
},
|
||||
"minimal/medium": {
|
||||
button: cn(sizes.medium.button, style.minimal.button),
|
||||
},
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variants;
|
||||
|
||||
type Section<TItem> = {
|
||||
type: "section";
|
||||
title?: string;
|
||||
items: TItem[];
|
||||
};
|
||||
|
||||
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
|
||||
const firstItem = data[0];
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
width === "full" ? "w-full" : "w-min",
|
||||
sizeClassName,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
(firstItem as Section<TItem>).type === "section" &&
|
||||
(firstItem as Section<TItem>).items !== undefined &&
|
||||
Array.isArray((firstItem as Section<TItem>).items)
|
||||
);
|
||||
});
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
}
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
|
||||
export interface SelectProps<TValue extends string | string[], TItem>
|
||||
extends Omit<Ariakit.SelectProps, "children"> {
|
||||
icon?: React.ReactNode;
|
||||
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
|
||||
placeholder?: React.ReactNode;
|
||||
value?: Ariakit.SelectProviderProps<TValue>["value"];
|
||||
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
|
||||
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
|
||||
label?: string | Ariakit.SelectLabelProps["render"];
|
||||
heading?: string;
|
||||
showHeading?: boolean;
|
||||
items?: TItem[] | Section<TItem>[];
|
||||
empty?: React.ReactNode;
|
||||
filter?:
|
||||
| boolean
|
||||
| MatchSorterOptions<TItem>
|
||||
| ((item: ItemFromSection<TItem>, search: string, title?: string) => boolean);
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
items: ItemFromSection<TItem>[],
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: {
|
||||
title?: string;
|
||||
startIndex: number;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
) => React.ReactNode);
|
||||
variant?: Variant;
|
||||
open?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
shortcut?: ShortcutDefinition;
|
||||
allowItemShortcuts?: boolean;
|
||||
clearSearchOnSelection?: boolean;
|
||||
dropdownIcon?: boolean | React.ReactNode;
|
||||
}
|
||||
|
||||
export function Select<TValue extends string | string[], TItem>({
|
||||
children,
|
||||
icon,
|
||||
text,
|
||||
placeholder,
|
||||
value,
|
||||
setValue,
|
||||
defaultValue,
|
||||
label,
|
||||
heading,
|
||||
showHeading = false,
|
||||
items,
|
||||
filter,
|
||||
empty = null,
|
||||
variant = "tertiary/small",
|
||||
open,
|
||||
setOpen,
|
||||
shortcut,
|
||||
allowItemShortcuts = true,
|
||||
disabled,
|
||||
clearSearchOnSelection = true,
|
||||
dropdownIcon,
|
||||
...props
|
||||
}: SelectProps<TValue, TItem>) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const searchable = items !== undefined && filter !== undefined;
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (!searchValue || !filter) return items;
|
||||
|
||||
if (typeof filter === "function") {
|
||||
if (isSection(items)) {
|
||||
return items
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) =>
|
||||
filter(item as ItemFromSection<TItem>, searchValue, section.title)
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
|
||||
}
|
||||
|
||||
if (typeof filter === "boolean" && filter) {
|
||||
if (isSection(items)) {
|
||||
return items
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: matchSorter(section.items, searchValue),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
return matchSorter(items, searchValue);
|
||||
}
|
||||
|
||||
if (isSection(items)) {
|
||||
return items
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: matchSorter(section.items, searchValue, filter),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
return matchSorter(items, searchValue, filter);
|
||||
}, [searchValue, items]);
|
||||
|
||||
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
|
||||
|
||||
const select = (
|
||||
<SelectProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
virtualFocus={searchable}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (clearSearchOnSelection) {
|
||||
setSearchValue("");
|
||||
}
|
||||
|
||||
if (setValue) {
|
||||
setValue(v as any);
|
||||
}
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"space-y-0.5 px-1 py-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
|
||||
<SelectTrigger
|
||||
icon={icon}
|
||||
variant={variant}
|
||||
text={text}
|
||||
placeholder={placeholder}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={heading}
|
||||
disabled={disabled}
|
||||
dropdownIcon={dropdownIcon}
|
||||
{...props}
|
||||
/>
|
||||
<SelectPopover>
|
||||
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
|
||||
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
|
||||
|
||||
<SelectList>
|
||||
{typeof children === "function" ? (
|
||||
matches.length > 0 ? (
|
||||
isSection(matches) ? (
|
||||
<SelectGroupedRenderer
|
||||
items={matches}
|
||||
children={children}
|
||||
enableItemShortcuts={enableItemShortcuts}
|
||||
/>
|
||||
) : (
|
||||
children(matches as ItemFromSection<TItem>[], {
|
||||
shortcutsEnabled: enableItemShortcuts,
|
||||
})
|
||||
)
|
||||
) : (
|
||||
empty
|
||||
)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
|
||||
if (searchable) {
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
React.startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
{select}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
return select;
|
||||
}
|
||||
|
||||
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
contentClassName?: string;
|
||||
};
|
||||
export interface SelectTriggerProps<TValue = any> extends AriaSelectProps {
|
||||
icon?: React.ReactNode;
|
||||
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
|
||||
placeholder?: React.ReactNode;
|
||||
variant?: Variant;
|
||||
shortcut?: ShortcutDefinition;
|
||||
tooltipTitle?: string;
|
||||
dropdownIcon?: boolean | React.ReactNode;
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
|
||||
({ className, children, contentClassName, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
export function SelectTrigger({
|
||||
icon,
|
||||
variant = "tertiary/small",
|
||||
text,
|
||||
shortcut,
|
||||
tooltipTitle,
|
||||
disabled,
|
||||
placeholder,
|
||||
dropdownIcon = false,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SelectTriggerProps) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
},
|
||||
disabled,
|
||||
});
|
||||
|
||||
const showTooltip = tooltipTitle || shortcut;
|
||||
const variantClasses = variants[variant];
|
||||
|
||||
let content: React.ReactNode = "";
|
||||
if (children) {
|
||||
content = children;
|
||||
} else if (text !== undefined) {
|
||||
if (typeof text === "function") {
|
||||
content = <SelectValue>{(value) => <>{text(value) ?? placeholder}</>}</SelectValue>;
|
||||
} else {
|
||||
content = text;
|
||||
}
|
||||
} else {
|
||||
content = (
|
||||
<SelectValue>
|
||||
{(value) => (
|
||||
<>
|
||||
{typeof value === "string"
|
||||
? value ?? placeholder
|
||||
: value.length === 0
|
||||
? placeholder
|
||||
: value.join(", ")}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Ariakit.TooltipProvider timeout={200}>
|
||||
<Ariakit.TooltipAnchor
|
||||
className="button"
|
||||
render={
|
||||
<Ariakit.Select
|
||||
className={cn(
|
||||
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variantClasses.button,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex grow items-center gap-0.5">
|
||||
{icon && <div className="-ml-1 flex-none">{icon}</div>}
|
||||
<div className="truncate">{content}</div>
|
||||
</div>
|
||||
{dropdownIcon === true ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
) : !dropdownIcon ? null : (
|
||||
dropdownIcon
|
||||
)}
|
||||
</Ariakit.TooltipAnchor>
|
||||
{showTooltip && (
|
||||
<Ariakit.Tooltip
|
||||
disabled={shortcut === undefined}
|
||||
className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{tooltipTitle ?? "Open menu"}</span>
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none")}
|
||||
shortcut={shortcut}
|
||||
variant={"small"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.Tooltip>
|
||||
)}
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectProviderProps<TValue extends string | string[]>
|
||||
extends Ariakit.SelectProviderProps<TValue> {}
|
||||
export function SelectProvider<TValue extends string | string[]>(
|
||||
props: SelectProviderProps<TValue>
|
||||
) {
|
||||
return <Ariakit.SelectProvider {...props} />;
|
||||
}
|
||||
|
||||
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
|
||||
export function ComboboxProvider(props: ComboboxProviderProps) {
|
||||
return <Ariakit.ComboboxProvider {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroupedRenderer<TItem>({
|
||||
items,
|
||||
children,
|
||||
enableItemShortcuts,
|
||||
}: {
|
||||
items: Section<TItem>[];
|
||||
children: (
|
||||
items: ItemFromSection<TItem>[],
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: { title?: string; startIndex: number; count: number };
|
||||
}
|
||||
) => React.ReactNode;
|
||||
enableItemShortcuts: boolean;
|
||||
}) {
|
||||
let count = 0;
|
||||
return (
|
||||
<>
|
||||
{items.map((section, index) => {
|
||||
const previousItem = items.at(index - 1);
|
||||
count += previousItem ? previousItem.items.length : 0;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{children(section.items as ItemFromSection<TItem>[], {
|
||||
shortcutsEnabled: enableItemShortcuts,
|
||||
section: {
|
||||
title: section.title,
|
||||
startIndex: count - 1,
|
||||
count: section.items.length,
|
||||
},
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
|
||||
export function SelectList(props: SelectListProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
|
||||
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
shortcut,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
},
|
||||
disabled: props.disabled,
|
||||
enabledOnInputElements: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Ariakit.SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(
|
||||
selectItemClasses,
|
||||
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
|
||||
props.className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export function SelectLinkItem({
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
to,
|
||||
...props
|
||||
}: SelectLinkItemProps) {
|
||||
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
onClick: React.ComponentProps<"button">["onClick"];
|
||||
}
|
||||
|
||||
export function SelectButtonItem({
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
onClick,
|
||||
...props
|
||||
}: SelectButtonItemProps) {
|
||||
const render = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn("block w-full text-left", selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function shortcutFromIndex(
|
||||
index: number,
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: { startIndex: number };
|
||||
}
|
||||
): ShortcutDefinition | undefined {
|
||||
if (!meta.shortcutsEnabled) return;
|
||||
|
||||
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
|
||||
|
||||
if (adjustedIndex > 9) return;
|
||||
if (adjustedIndex === 9) {
|
||||
adjustedIndex = -1;
|
||||
}
|
||||
|
||||
return { key: String(adjustedIndex + 1) };
|
||||
}
|
||||
|
||||
export interface SelectSeparatorProps extends React.ComponentProps<"div"> {}
|
||||
|
||||
export function SelectSeparator(props: SelectSeparatorProps) {
|
||||
return <div {...props} className={cn("h-px bg-charcoal-700", props.className)} />;
|
||||
}
|
||||
|
||||
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
|
||||
|
||||
export function SelectGroup(props: SelectGroupProps) {
|
||||
return <Ariakit.SelectGroup {...props} />;
|
||||
}
|
||||
|
||||
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
|
||||
|
||||
export function SelectGroupLabel(props: SelectGroupLabelProps) {
|
||||
return (
|
||||
<Ariakit.SelectGroupLabel
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-[1.375rem] items-center border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
|
||||
export function SelectHeading({ render, ...props }: SelectHeadingProps) {
|
||||
return (
|
||||
<div className="flex h-[1.375rem] flex-none cursor-default items-center gap-2 border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright">
|
||||
<Ariakit.SelectHeading render={<>{render}</>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
|
||||
export function SelectPopover({
|
||||
gutter = 5,
|
||||
shift = 0,
|
||||
unmountOnHide = true,
|
||||
className,
|
||||
...props
|
||||
}: SelectPopoverProps) {
|
||||
return (
|
||||
<Ariakit.SelectPopover
|
||||
gutter={gutter}
|
||||
shift={shift}
|
||||
unmountOnHide={unmountOnHide}
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(480px,var(--popover-available-height))]",
|
||||
"origin-[var(--popover-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
export interface SelectLabelProps extends Ariakit.SelectLabelProps {}
|
||||
//currently unstyled
|
||||
export function SelectLabel(props: SelectLabelProps) {
|
||||
return <Ariakit.SelectLabel {...props} />;
|
||||
}
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
export interface ComboBoxProps extends Ariakit.ComboboxProps {
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
export function ComboBox({
|
||||
autoSelect = true,
|
||||
placeholder = "Filter options",
|
||||
shortcut,
|
||||
...props
|
||||
}: ComboBoxProps) {
|
||||
return (
|
||||
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
|
||||
<Ariakit.Combobox
|
||||
autoSelect={autoSelect}
|
||||
render={<input placeholder={placeholder} />}
|
||||
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
|
||||
{...props}
|
||||
/>
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useReducer } from "react";
|
||||
|
||||
type SelectedItemsContext = {
|
||||
selectedItems: Set<string>;
|
||||
select: (items: string | string[]) => void;
|
||||
deselect: (items: string | string[]) => void;
|
||||
toggle: (items: string | string[]) => void;
|
||||
deselectAll: () => void;
|
||||
has: (item: string) => boolean;
|
||||
hasAll: (items: string[]) => boolean;
|
||||
};
|
||||
|
||||
const SelectedItemsContext = createContext<SelectedItemsContext>({} as SelectedItemsContext);
|
||||
|
||||
export function useSelectedItems(enabled = true) {
|
||||
const context = useContext(SelectedItemsContext);
|
||||
if (!context && enabled) {
|
||||
throw new Error("useSelectedItems must be used within a SelectedItemsProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function SelectedItemsProvider({
|
||||
initialSelectedItems,
|
||||
maxSelectedItemCount,
|
||||
children,
|
||||
}: {
|
||||
initialSelectedItems: string[];
|
||||
maxSelectedItemCount?: number;
|
||||
children: React.ReactNode | ((context: SelectedItemsContext) => React.ReactNode);
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(selectedItemsReducer, {
|
||||
items: new Set<string>(initialSelectedItems),
|
||||
maxSelectedItemCount,
|
||||
});
|
||||
|
||||
const select = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "select", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const deselect = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "deselect", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((items: string | string[]) => {
|
||||
dispatch({ type: "toggle", items: Array.isArray(items) ? items : [items] });
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
dispatch({ type: "deselectAll" });
|
||||
}, []);
|
||||
|
||||
const has = useCallback((item: string) => state.items.has(item), [state]);
|
||||
|
||||
const hasAll = useCallback(
|
||||
(items: string[]) => items.every((item) => state.items.has(item)),
|
||||
[state]
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectedItemsContext.Provider
|
||||
value={{ selectedItems: state.items, select, deselect, toggle, deselectAll, has, hasAll }}
|
||||
>
|
||||
{typeof children === "function"
|
||||
? children({
|
||||
selectedItems: state.items,
|
||||
select,
|
||||
deselect,
|
||||
toggle,
|
||||
deselectAll,
|
||||
has,
|
||||
hasAll,
|
||||
})
|
||||
: children}
|
||||
</SelectedItemsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type SelectItemsAction = {
|
||||
type: "select";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type DeSelectItemsAction = {
|
||||
type: "deselect";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type DeselectAllItemsAction = {
|
||||
type: "deselectAll";
|
||||
};
|
||||
|
||||
type ToggleItemsAction = {
|
||||
type: "toggle";
|
||||
items: string[];
|
||||
};
|
||||
|
||||
type Action = SelectItemsAction | DeSelectItemsAction | ToggleItemsAction | DeselectAllItemsAction;
|
||||
|
||||
function selectedItemsReducer(
|
||||
state: { items: Set<string>; maxSelectedItemCount?: number },
|
||||
action: Action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case "select":
|
||||
const items = new Set([...state.items, ...action.items]);
|
||||
return { ...state, items: cappedSet(items, state.maxSelectedItemCount) };
|
||||
case "deselect":
|
||||
const newItems = new Set(state.items);
|
||||
action.items.forEach((item) => {
|
||||
newItems.delete(item);
|
||||
});
|
||||
return { ...state, items: cappedSet(newItems, state.maxSelectedItemCount) };
|
||||
case "toggle":
|
||||
let newSet = new Set(state.items);
|
||||
action.items.forEach((item) => {
|
||||
if (newSet.has(item)) {
|
||||
newSet.delete(item);
|
||||
} else {
|
||||
newSet.add(item);
|
||||
}
|
||||
});
|
||||
return { ...state, items: cappedSet(newSet, state.maxSelectedItemCount) };
|
||||
case "deselectAll":
|
||||
return { ...state, items: new Set<string>() };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function cappedSet(set: Set<string>, max?: number) {
|
||||
if (!max) {
|
||||
return set;
|
||||
}
|
||||
|
||||
if (set.size <= max) {
|
||||
return set;
|
||||
}
|
||||
|
||||
console.warn(`Selected items exceeded the maximum count of ${max}.`);
|
||||
|
||||
return new Set([...set].slice(0, max));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const sizes = {
|
||||
"secondary/small":
|
||||
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
|
||||
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
|
||||
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
size?: keyof typeof sizes;
|
||||
width?: "content" | "full";
|
||||
};
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
|
||||
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
|
||||
const sizeClassName = sizes[size];
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
width === "full" ? "w-full" : "w-min",
|
||||
sizeClassName,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"space-y-0.5 px-1 py-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
|
||||
({ className, children, contentClassName, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -106,7 +106,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-4 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
|
||||
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
@@ -155,7 +155,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
|
||||
const flexClasses = cn(
|
||||
"flex w-full whitespace-nowrap px-4 py-3 text-xs text-text-dimmed",
|
||||
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
|
||||
alignment === "left"
|
||||
? "justify-start text-left"
|
||||
: alignment === "center"
|
||||
@@ -170,7 +170,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
"text-xs text-charcoal-400",
|
||||
to || onClick || hasAction
|
||||
? "cursor-pointer group-hover/table-row:bg-charcoal-900"
|
||||
: "px-4 py-3 align-middle",
|
||||
: "px-3 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
className
|
||||
|
||||
@@ -528,7 +528,7 @@ export type Tree<TData> = {
|
||||
/** A tree but flattened so it can easily be used for DOM elements */
|
||||
export type FlatTreeItem<TData> = {
|
||||
id: string;
|
||||
parentId: string | undefined;
|
||||
parentId?: string | undefined;
|
||||
children: string[];
|
||||
hasChildren: boolean;
|
||||
/** The indentation level, the root is 0 */
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
} from "../primitives/SimpleSelect";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
FilterableEnvironment,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ArrowPathIcon, NoSymbolIcon } from "@heroicons/react/20/solid";
|
||||
import { BulkActionType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function BulkActionStatusCombo({
|
||||
type,
|
||||
className,
|
||||
iconClassName,
|
||||
}: {
|
||||
type: BulkActionType;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<BulkActionIcon type={type} className={cn("h-4 w-4", iconClassName)} />
|
||||
<BulkActionLabel type={type} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkActionLabel({ type }: { type: BulkActionType }) {
|
||||
return <span className={bulkActionClassName(type)}>{bulkActionTitle(type)}</span>;
|
||||
}
|
||||
|
||||
export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return <ArrowPathIcon className={cn(bulkActionClassName(type), className)} />;
|
||||
case "CANCEL":
|
||||
return <NoSymbolIcon className={cn(bulkActionClassName(type), className)} />;
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionClassName(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "text-indigo-500";
|
||||
case "CANCEL":
|
||||
return "text-rose-500";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionTitle(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "Replay";
|
||||
case "CANCEL":
|
||||
return "Cancel";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bulkActionVerb(type: BulkActionType): string {
|
||||
switch (type) {
|
||||
case "REPLAY":
|
||||
return "Replaying";
|
||||
case "CANCEL":
|
||||
return "Canceling";
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ export function DeploymentError({ errorData }: DeploymentErrorProps) {
|
||||
maxLines={20}
|
||||
/>
|
||||
)}
|
||||
{errorData.stderr && (
|
||||
<>
|
||||
<DeploymentErrorHeader title="Error logs:" />
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={errorData.stderr}
|
||||
maxLines={20}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function EnabledStatus({ enabled }: { enabled: boolean }) {
|
||||
type EnabledStatusProps = {
|
||||
enabled: boolean;
|
||||
enabledIcon?: React.ComponentType<any>;
|
||||
disabledIcon?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
export function EnabledStatus({
|
||||
enabled,
|
||||
enabledIcon = CheckCircleIcon,
|
||||
disabledIcon = BoltSlashIcon,
|
||||
}: EnabledStatusProps) {
|
||||
const EnabledIcon = enabledIcon;
|
||||
const DisabledIcon = disabledIcon;
|
||||
|
||||
switch (enabled) {
|
||||
case true:
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-success">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<EnabledIcon className="size-4" />
|
||||
Enabled
|
||||
</div>
|
||||
);
|
||||
case false:
|
||||
return (
|
||||
<div className="text-dimmed flex items-center gap-1 text-xs">
|
||||
<BoltSlashIcon className="h-4 w-4" />
|
||||
<DisabledIcon className="size-4" />
|
||||
Disabled
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "~/components/primitives/Dialog";
|
||||
|
||||
type RollbackDeploymentDialogProps = {
|
||||
projectId: string;
|
||||
deploymentShortCode: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function RollbackDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Roll back to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/small"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Roll back deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user