Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4243cb2451 | |||
| 7e8a0d96d2 | |||
| 4d19dec71a | |||
| 76a5ac293e | |||
| 668b34d25e | |||
| 6a2d033f32 | |||
| e71df640a9 | |||
| 330f6c548e | |||
| 4d2412a0fe | |||
| 21a4fabc28 | |||
| 3418aa614b | |||
| 88a0754ae0 | |||
| f3e4597ef4 | |||
| 2bf04ece54 | |||
| 295b2eb3b6 | |||
| f0182abc83 | |||
| 1105b9b71a | |||
| 7077dbf65e | |||
| 6b355ab9ad | |||
| 68d7139ebe | |||
| c58f6f7a68 | |||
| 6516e15bd8 | |||
| 7f2365f4e0 | |||
| f560594610 | |||
| ebd9767926 | |||
| 1c631a1b6f | |||
| 110a8ea48c | |||
| 10f0ef3301 | |||
| 2e4a6307b4 | |||
| 30ea5eb13a | |||
| 5846f30228 | |||
| 3afa42c209 | |||
| 86b1628953 | |||
| ea23dbd297 | |||
| 9065e64be8 | |||
| 9970b9b68e | |||
| b4113134ad | |||
| 2a07ea42f1 | |||
| 91afa5ebbf | |||
| 65262dc3d7 | |||
| 9105701ae0 | |||
| 9b35cc484b | |||
| 30a04a5a06 | |||
| 493315af48 | |||
| 8db1da69e9 | |||
| cd7a45101e | |||
| 29d107dc0a | |||
| cf7dc8d719 | |||
| 9ced599b19 | |||
| 67592ec2b4 | |||
| f7bf7bc268 | |||
| bb57426a0d | |||
| 3ab7eb9c7a | |||
| 2892efad04 | |||
| 979ba51d2f | |||
| 364ea565ed | |||
| 58252728f6 | |||
| 5eaad0577e | |||
| 00f1103deb | |||
| c37622e7b6 | |||
| d67023aa8f | |||
| d9bfe55a8c | |||
| f4a18feca0 | |||
| 0acd052061 | |||
| 6bf3bbcfd7 | |||
| 1077709e15 | |||
| 54017cbffa | |||
| 39ef733a34 | |||
| 736f577c25 | |||
| b7de02ec14 | |||
| 90db0f6e01 | |||
| 6df8069c0e | |||
| b714d6fabf | |||
| 576715374e | |||
| 9046ea4f23 | |||
| 7e209e0771 | |||
| 3792394e5c | |||
| 4305a23668 | |||
| e7fc592cd9 | |||
| cb12a73db6 | |||
| 31fc9e12b7 | |||
| 2bdbedcdd8 | |||
| 332166a098 | |||
| 14f19066b5 | |||
| af7a7681cf | |||
| c044cb125e | |||
| 8ba226b99a | |||
| 7d4708839f | |||
| 532fdcad47 | |||
| 186942de5d | |||
| 23b43be952 | |||
| ea0956464b | |||
| eca79afced | |||
| 58861a0983 | |||
| 1b3a2f2e5b | |||
| 653d974da7 | |||
| 332854bbcb | |||
| 911447304c | |||
| 2be1062f1e | |||
| 64be7b04e8 | |||
| 925c463fe5 | |||
| e1bd6b3355 | |||
| fd90f79ede | |||
| 5d303ce6f2 | |||
| d98221ba7f | |||
| 29026a379b | |||
| 0706482232 | |||
| 2e273e8fe2 | |||
| a5d59de39e | |||
| 71808fafd1 | |||
| 82ebfc7908 | |||
| f0996686e9 | |||
| e422fb5e4d | |||
| ceac10b098 | |||
| 365fabf9a0 | |||
| 21e1375760 | |||
| d7656314d6 | |||
| 5368bcf6bd | |||
| adf5970245 | |||
| aca2f54cb7 | |||
| 6313db698a | |||
| c9811beae2 | |||
| 97533cba2e | |||
| e92459bf1e | |||
| cc74cae393 | |||
| 58def055d0 | |||
| 41f5261fe5 | |||
| ce1a54eb3b | |||
| 25285488f8 | |||
| befdcef1f0 | |||
| fa18e6c012 | |||
| 4194bce4a3 | |||
| da08e5015f | |||
| 9b7472844d | |||
| eace78fa04 | |||
| e81b2a7839 | |||
| cdcfc81aca | |||
| 816b0f9e3d | |||
| 6fb073672d | |||
| 9203bd8e67 | |||
| 3252d9ec31 | |||
| 45fdf35e20 | |||
| 53841d7bf0 | |||
| d2c779eb0f | |||
| cbe51707a5 | |||
| 3d7a6d8e9d | |||
| 72cdb5edc4 | |||
| 1f11a8dde0 | |||
| af427aa32d | |||
| 212f8539c3 | |||
| 9a5e6e58be | |||
| c31700ae5f | |||
| cfb96859b3 | |||
| 9bc641d15e | |||
| a79075908e | |||
| 7f9091f205 | |||
| 9f6887b048 | |||
| 982906cbad | |||
| 90514a73bb | |||
| 2d63c5db50 | |||
| 768036a223 | |||
| 2d8a41b18b |
+25
-3
@@ -31,10 +31,32 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Resend is an email service used for signing in to Trigger.dev via a Magic Link.
|
||||
# Emails will print to the console if you leave these commented out
|
||||
# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link.
|
||||
# If none are configured, emails will print to the console instead.
|
||||
# Uncomment one of the following blocks to allow delivery of
|
||||
|
||||
# Resend
|
||||
### Visit https://resend.com, create an account and get your API key. Then insert it below along with your From and Reply To email addresses. Visit https://resend.com/docs for more information.
|
||||
# RESEND_API_KEY=<api_key>
|
||||
# EMAIL_TRANSPORT=resend
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
# RESEND_API_KEY=
|
||||
|
||||
# Generic SMTP
|
||||
### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information
|
||||
### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587)
|
||||
# EMAIL_TRANSPORT=smtp
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
|
||||
# AWS Simple Email Service
|
||||
### Authentication is configured using the default Node.JS credentials provider chain (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain)
|
||||
# EMAIL_TRANSPORT=aws-ses
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations.
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
unitTests:
|
||||
name: "🧪 Unit Tests"
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -30,5 +30,17 @@ jobs:
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🧪 Run Unit Tests
|
||||
run: pnpm run test
|
||||
- name: 🧪 Run Webapp Unit Tests
|
||||
run: pnpm run test --filter webapp
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
SESSION_SECRET: "secret"
|
||||
MAGIC_LINK_SECRET: "secret"
|
||||
ENCRYPTION_KEY: "secret"
|
||||
|
||||
- name: 🧪 Run Package Unit Tests
|
||||
run: pnpm run test --filter "@trigger.dev/*"
|
||||
|
||||
- name: 🧪 Run Internal Unit Tests
|
||||
run: pnpm run test --filter "@internal/*"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
Vendored
+2
-6
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
"recommendations": ["bierner.comment-tagged-templates"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -13,6 +13,15 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug realtimeStreams.test.ts",
|
||||
"command": "pnpm run test -t RealtimeStreams",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"cwd": "${workspaceFolder}/apps/webapp",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
@@ -36,6 +45,14 @@
|
||||
"cwd": "${workspaceFolder}/references/v3-catalog",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug Dev Next.js Realtime",
|
||||
"command": "pnpm exec trigger dev",
|
||||
"cwd": "${workspaceFolder}/references/nextjs-realtime",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
|
||||
@@ -661,6 +661,7 @@ provider.listen();
|
||||
|
||||
const taskMonitor = new TaskMonitor({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: KUBERNETES_NAMESPACE,
|
||||
onIndexFailure: async (deploymentId, details) => {
|
||||
logger.log("Indexing failed", { deploymentId, details });
|
||||
|
||||
|
||||
@@ -160,7 +160,10 @@ export class TaskMonitor {
|
||||
|
||||
let reason = rawReason || "Unknown error";
|
||||
let logs = rawLogs || "";
|
||||
let overrideCompletion = false;
|
||||
|
||||
/** This will only override existing task errors. It will not crash the run. */
|
||||
let onlyOverrideExistingError = exitCode === EXIT_CODE_CHILD_NONZERO;
|
||||
|
||||
let errorCode: TaskRunInternalError["code"] = TaskRunErrorCodes.POD_UNKNOWN_ERROR;
|
||||
|
||||
switch (rawReason) {
|
||||
@@ -185,10 +188,8 @@ export class TaskMonitor {
|
||||
}
|
||||
break;
|
||||
case "OOMKilled":
|
||||
overrideCompletion = true;
|
||||
reason = `${
|
||||
exitCode === EXIT_CODE_CHILD_NONZERO ? "Child process" : "Parent process"
|
||||
} ran out of memory! Try choosing a machine preset with more memory for this task.`;
|
||||
reason =
|
||||
"[TaskMonitor] Your task ran out of memory. Try increasing the machine specs. If this doesn't fix it there might be a memory leak.";
|
||||
errorCode = TaskRunErrorCodes.TASK_PROCESS_OOM_KILLED;
|
||||
break;
|
||||
default:
|
||||
@@ -199,7 +200,7 @@ export class TaskMonitor {
|
||||
exitCode,
|
||||
reason,
|
||||
logs,
|
||||
overrideCompletion,
|
||||
overrideCompletion: onlyOverrideExistingError,
|
||||
errorCode,
|
||||
} satisfies FailureDetails;
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
"dev": "wrangler dev",
|
||||
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
@@ -15,7 +16,7 @@
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"ulidx": "^2.2.1",
|
||||
"zod": "3.22.3",
|
||||
"zod": "3.23.8",
|
||||
"zod-error": "1.5.0"
|
||||
}
|
||||
}
|
||||
+2
-20
@@ -17,12 +17,9 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
if (!env.REWRITE_HOSTNAME) throw new Error("Missing REWRITE_HOSTNAME");
|
||||
console.log("url", request.url);
|
||||
|
||||
if (!queueingIsEnabled(env)) {
|
||||
console.log("Missing AWS credentials. Passing through to the origin.");
|
||||
return redirectToOrigin(request, env);
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -42,25 +39,10 @@ export default {
|
||||
}
|
||||
|
||||
//the same request but with the hostname (and port) changed
|
||||
return redirectToOrigin(request, env);
|
||||
return fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
function redirectToOrigin(request: Request, env: Env) {
|
||||
const newUrl = new URL(request.url);
|
||||
newUrl.hostname = env.REWRITE_HOSTNAME;
|
||||
newUrl.port = env.REWRITE_PORT || newUrl.port;
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
};
|
||||
|
||||
console.log("rewritten url", newUrl.toString());
|
||||
return fetch(newUrl.toString(), requestInit);
|
||||
}
|
||||
|
||||
function queueingIsEnabled(env: Env) {
|
||||
return (
|
||||
env.AWS_SQS_ACCESS_KEY_ID &&
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function SideMenuRightClosedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="12" y="4" width="1" height="12" fill="currentColor" />
|
||||
<rect x="2.5" y="3.5" width="15" height="13" rx="2.5" stroke="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function LyftLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="48"
|
||||
height="34"
|
||||
viewBox="0 0 48 34"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 0.593341H7.28904V20.1709C7.28904 23.2692 8.70514 25.1147 9.82493 25.9058C8.63943 26.9605 5.01673 27.8834 2.3162 25.6421C0.724053 24.3209 0 22.1485 0 20.1049V0.593341ZM45.5536 16.856V14.7986H47.7767V7.58069H45.3354C44.3982 3.24724 40.5432 0 35.9328 0C30.6217 0 26.3164 4.30894 26.3164 9.62402V26.48C27.8295 26.6928 29.6322 26.4536 31.1659 25.1807C32.7578 23.8594 33.4818 21.6872 33.4818 19.6436V19.0226H37.1222V11.8047H33.4818V9.62402H33.4905C33.4905 8.27426 34.5839 7.18004 35.9328 7.18004C37.2815 7.18004 38.3792 8.27426 38.3792 9.62402V16.856C38.3792 22.1711 42.6891 26.48 48 26.48V19.3C46.6513 19.3 45.5536 18.2057 45.5536 16.856ZM17.9474 7.58069V18.1737C17.9474 18.7751 17.4488 19.2626 16.8337 19.2626C16.2185 19.2626 15.7199 18.7751 15.7199 18.1737V7.58069H8.50752V20.0392C8.50752 22.2803 9.26697 25.1147 12.7231 26.0376C16.1828 26.9615 18.1899 25.049 18.1899 25.049C18.007 26.3089 16.8213 27.2318 14.9113 27.4296C13.4661 27.5791 11.6178 27.1 10.6959 26.7045V33.305C13.0451 33.9983 15.5298 34.2223 17.9614 33.7501C22.3744 32.8932 25.1596 29.2019 25.1596 24.2908V7.58069H17.9474Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function MiddayLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={102}
|
||||
height={30}
|
||||
viewBox="0 0 100 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M14.347 0a14.931 14.931 0 0 0-6.282 1.68l6.282 10.88V0Zm0 17.443L8.067 28.32a14.933 14.933 0 0 0 6.28 1.68V17.443ZM15.652 30V17.432l6.285 10.887A14.932 14.932 0 0 1 15.652 30Zm0-17.43V0c2.26.097 4.392.693 6.287 1.682l-6.287 10.889ZM2.336 23.068l10.884-6.284-6.284 10.884a15.093 15.093 0 0 1-4.6-4.6Zm25.33-16.132-10.88 6.282 6.282-10.88a15.094 15.094 0 0 1 4.598 4.598ZM2.335 6.934a15.094 15.094 0 0 1 4.6-4.6l6.284 10.884L2.335 6.934Zm-.654 1.13A14.931 14.931 0 0 0 0 14.35h12.568L1.681 8.064Zm0 13.873a14.932 14.932 0 0 1-1.68-6.282h12.562L1.682 21.938Zm15.754-7.587H30a14.93 14.93 0 0 0-1.68-6.285L17.435 14.35Zm10.884 7.586-10.878-6.28H30a14.932 14.932 0 0 1-1.68 6.28Zm-11.533-5.151 6.281 10.88a15.092 15.092 0 0 0 4.598-4.599l-10.88-6.281Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M92.34 11.912h1.637l2.995 8.223 2.884-8.223h1.619l-4 11.107c-.372 1.06-1.08 1.544-2.196 1.544h-1.172v-1.358h1.024c.502 0 .8-.186.986-.707l.353-.912h-.52l-3.61-9.674ZM82.744 14.814c.39-1.916 1.916-3.126 4.018-3.126 2.549 0 3.963 1.489 3.963 4.13v3.964c0 .446.186.632.614.632h.39v1.358h-.65c-1.005 0-1.88-.335-1.861-1.544-.428.93-1.544 1.767-3.107 1.767-1.954 0-3.535-1.041-3.535-2.79 0-2.028 1.544-2.55 3.702-2.977l2.921-.558c-.018-1.712-.818-2.53-2.437-2.53-1.265 0-2.102.65-2.4 1.804l-1.618-.13Zm1.432 4.39c0 .8.689 1.452 2.14 1.433 1.637 0 2.92-1.153 2.92-3.442v-.167l-2.362.41c-1.47.26-2.698.371-2.698 1.767ZM80.129 8.563v13.21h-1.377l-.056-1.452c-.558 1.042-1.618 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.451 0 2.493.558 3.051 1.562V8.563h1.526Zm-7.145 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM68.936 8.563v13.21H67.56l-.056-1.452c-.558 1.042-1.619 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.45 0 2.493.558 3.05 1.562V8.563h1.526Zm-7.144 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM56.212 11.912h1.525v9.86h-1.525v-9.86Zm-.037-1.544V8.6h1.6v1.768h-1.6ZM40.224 11.912h1.395l.056 1.674c.446-1.21 1.47-1.898 2.846-1.898 1.414 0 2.438.763 2.865 2.084.428-1.34 1.47-2.084 3.014-2.084 1.973 0 3.126 1.377 3.126 3.74v6.344H52v-5.897c0-1.805-.707-2.828-1.916-2.828-1.544 0-2.437 1.041-2.437 2.846v5.88H46.12v-5.899c0-1.767-.725-2.827-1.916-2.827-1.526 0-2.456 1.079-2.456 2.827v5.898h-1.525v-9.86Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export function TldrawLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="96"
|
||||
height="25"
|
||||
viewBox="0 0 96 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.3031 0.283066C15.4501 0.283066 18.5971 0.281726 21.7441 0.283066C23.5701 0.283066 24.6473 1.36288 24.6486 3.19829C24.6513 9.4695 24.6513 15.7407 24.6486 22.0133C24.6486 23.8607 23.5808 24.9325 21.7521 24.9338C15.4581 24.9352 9.16417 24.9352 2.87018 24.9338C1.08836 24.9338 0.000510729 23.8446 -0.000828987 22.0521C-0.0021687 15.7581 -0.0021687 9.46414 0.000510729 3.17015C0.00185044 1.34546 1.08568 0.284406 2.93047 0.283066C6.05469 0.281726 9.18024 0.283066 12.3045 0.283066H12.3031ZM12.1223 16.9518C12.0499 17.7543 11.6386 18.3116 11.088 18.8006C10.8201 19.0391 10.4798 19.3713 10.8308 19.6902C11.0532 19.8938 11.5475 19.9997 11.8356 19.9019C13.7808 19.2374 15.2183 16.2498 14.582 14.3019C14.2725 13.3533 13.2825 12.6178 12.3674 12.6594C11.4095 12.7022 10.5682 13.4016 10.3525 14.3327C10.0819 15.4982 10.6553 16.3637 12.1236 16.9518H12.1223ZM12.4143 5.20384C11.2354 5.21858 10.315 6.1604 10.3257 7.34337C10.3364 8.52768 11.2662 9.47218 12.4331 9.48155C13.6134 9.49093 14.586 8.49016 14.5605 7.29112C14.5351 6.10815 13.5892 5.18776 12.4157 5.2025L12.4143 5.20384Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M85.3405 16.6586C85.4195 16.4443 85.5267 16.2353 85.5722 16.0142C85.9849 13.9846 86.3961 11.9549 86.786 9.92119C86.8784 9.44024 87.1035 9.22588 87.6006 9.25402C88.3093 9.29287 89.2229 9.03832 89.6731 9.39067C90.1206 9.73899 90.138 10.6768 90.284 11.3681C90.6176 12.9597 90.9096 14.5606 91.2339 16.1536C91.2674 16.317 91.408 16.459 91.4978 16.6117C91.5808 16.455 91.7269 16.3023 91.7389 16.1415C91.889 14.1185 92.0323 12.0942 92.1449 10.0699C92.1784 9.48043 92.4021 9.23124 93.0063 9.25134C93.9602 9.28215 94.9167 9.25937 95.9992 9.25937C95.8612 10.157 95.7433 10.9809 95.604 11.8022C95.1793 14.3061 94.7292 16.8073 94.3259 19.3153C94.2201 19.9731 93.995 20.308 93.2582 20.2598C92.3472 20.1995 91.4268 20.2008 90.5158 20.2598C89.8781 20.3013 89.6316 20.0227 89.5284 19.4586C89.2564 17.9689 88.9724 16.4818 88.683 14.9947C88.6415 14.783 88.5397 14.5847 88.4647 14.3798L88.2543 14.3945C88.182 14.7026 88.1029 15.0094 88.0373 15.3189C87.7412 16.735 87.4425 18.1524 87.1598 19.5712C87.0593 20.0776 86.7873 20.2692 86.2702 20.2531C85.2668 20.2236 84.262 20.2249 83.2585 20.2531C82.807 20.2651 82.6048 20.0736 82.5351 19.6596C81.9979 16.4724 81.4526 13.2879 80.9247 10.0994C80.8149 9.4322 80.985 9.26607 81.6763 9.26071C82.4346 9.25536 83.1942 9.28081 83.9525 9.25134C84.3799 9.23526 84.538 9.37995 84.5688 9.81C84.7148 11.9013 84.8863 13.9899 85.0511 16.0785C85.0645 16.25 85.0872 16.4202 85.106 16.5916L85.3405 16.6586Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M58.7188 5.17457C58.7188 5.4961 58.7188 5.73457 58.7188 5.97438C58.7188 10.3928 58.7188 14.8125 58.7188 19.2309C58.7188 20.1981 58.6692 20.2437 57.6832 20.245C56.7266 20.245 55.7701 20.245 54.7492 20.245C54.7305 19.8846 54.7332 19.5778 54.6943 19.2764C54.6648 19.05 54.5844 18.8316 54.5268 18.6092C54.3446 18.7432 54.1276 18.8477 53.9856 19.0165C53.3988 19.7132 52.7678 20.3107 51.7952 20.4178C50.0951 20.6041 48.6656 19.7091 48.5263 18.0157C48.3534 15.9151 48.399 13.7863 48.5075 11.6775C48.5731 10.3968 49.3663 9.47371 50.6618 9.16424C52.0082 8.84405 53.1804 9.15888 54.0472 10.3244C54.1651 10.4825 54.3621 10.583 54.5215 10.7103C54.5818 10.512 54.6903 10.3137 54.693 10.1141C54.7077 8.75295 54.7198 7.39046 54.693 6.03065C54.6809 5.38892 54.9435 5.15045 55.5691 5.16653C56.5913 5.19332 57.6135 5.17323 58.7188 5.17323V5.17457ZM54.6956 14.6879C54.6956 14.6879 54.6983 14.6879 54.6997 14.6879C54.6997 14.2431 54.7064 13.7983 54.6983 13.3535C54.6863 12.6984 54.4009 12.4827 53.522 12.4465C52.816 12.4171 52.4181 12.681 52.3953 13.2839C52.3605 14.1949 52.3578 15.1086 52.394 16.0182C52.4235 16.7778 53.1175 17.086 54.149 16.7658C54.3728 16.6961 54.6313 16.3866 54.6715 16.1522C54.7532 15.6766 54.6956 15.1782 54.6956 14.6892V14.6879Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M79.2702 14.5379C79.2702 14.9612 79.2809 15.3859 79.2675 15.8093C79.2528 16.3103 79.2072 16.7886 79.9052 16.9105C80.0419 16.9346 80.1999 17.2937 80.2107 17.504C80.2482 18.2609 80.2442 19.0219 80.2107 19.7788C80.204 19.9383 79.991 20.2276 79.8891 20.2209C78.942 20.1566 77.9747 20.1312 77.061 19.9034C76.5974 19.7882 76.1821 19.3247 75.8365 18.9375C75.5176 18.5811 75.3328 18.5651 75.0796 18.9563C74.1592 20.3804 72.7833 20.6202 71.2587 20.3549C69.998 20.1352 69.1861 19.1559 69.1192 17.8805C69.1004 17.5241 69.1031 17.1664 69.1084 16.81C69.1299 14.988 70.1775 13.8948 72.0036 13.8265C72.873 13.7943 73.7439 13.8359 74.6133 13.805C74.8692 13.7957 75.1197 13.6537 75.3743 13.5733C75.2376 13.241 75.18 12.8257 74.9416 12.6006C74.7326 12.4037 74.3159 12.3314 74.0038 12.3582C72.8087 12.456 71.6124 12.5725 70.4321 12.7735C69.8815 12.8672 69.6778 12.7574 69.6872 12.2108C69.6993 11.5423 69.6483 10.8684 69.7127 10.2052C69.7354 9.96676 69.9712 9.60906 70.1789 9.54877C72.2661 8.94992 74.3896 8.7436 76.5171 9.29556C78.3324 9.7658 79.2072 10.9876 79.2702 13.0012C79.2863 13.5143 79.2729 14.0274 79.2729 14.5405L79.2702 14.5379ZM74.0426 15.6244C73.6956 15.7972 73.0177 15.9258 72.944 16.2112C72.6828 17.22 72.8797 17.721 74.0212 17.512C74.3668 17.4491 74.7138 17.3379 75.0286 17.1838C75.4359 16.9842 75.5833 16.2473 75.235 15.9941C74.9576 15.7932 74.5504 15.7704 74.0426 15.623V15.6244Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M36.6847 6.1808C36.6847 6.91899 36.6834 7.60224 36.6847 8.28549C36.6874 9.2032 36.7437 9.25813 37.6855 9.25947C38.3527 9.26081 39.0212 9.25947 39.7862 9.25947C39.7862 10.4116 39.8062 11.5343 39.758 12.6543C39.7527 12.7816 39.3628 12.9745 39.1391 12.9933C38.563 13.0415 37.9802 13.0187 37.4001 13.0067C36.8937 12.9959 36.6646 13.2197 36.6807 13.7328C36.6981 14.2901 36.6727 14.8488 36.6887 15.4047C36.7102 16.1282 36.9848 16.4202 37.7016 16.4604C38.1906 16.4886 38.6822 16.4725 39.1739 16.4698C39.5731 16.4685 39.8196 16.6373 39.8223 17.0566C39.829 18.0802 39.825 19.105 39.825 20.2304C37.802 20.1822 35.7576 20.6095 34.0227 19.2176C33.1144 18.4888 32.7567 17.4572 32.691 16.3291C32.6468 15.5735 32.6709 14.8126 32.6669 14.0543C32.6615 13.0991 32.5879 13.0241 31.6501 13.0053C31.4973 13.0026 31.3433 12.9852 31.0927 12.9665C31.0927 11.8706 31.0633 10.7854 31.1249 9.70693C31.1343 9.54349 31.5683 9.28358 31.8081 9.27956C32.4512 9.26885 32.695 8.99688 32.6709 8.38061C32.6495 7.84607 32.6133 7.30214 32.695 6.77831C32.7299 6.55458 33.0407 6.21295 33.239 6.20358C34.3469 6.14731 35.4602 6.17678 36.6861 6.17678L36.6847 6.1808Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M46.9683 20.2397C45.9474 20.2397 45.0766 20.2732 44.2098 20.2317C42.7214 20.162 41.7796 19.2631 41.5947 17.7894C41.5639 17.5469 41.5545 17.3004 41.5545 17.0552C41.5518 13.4206 41.5518 9.78594 41.5545 6.15129C41.5545 5.30191 41.6778 5.17732 42.5124 5.17464C43.5547 5.17062 44.5983 5.17464 45.7063 5.17464C45.7063 5.531 45.7063 5.79493 45.7063 6.05885C45.7063 9.27015 45.7143 12.4814 45.6982 15.6914C45.6956 16.2206 45.7746 16.5515 46.4177 16.5167C46.5945 16.5073 46.9388 16.7551 46.9442 16.8945C46.9884 17.9957 46.9683 19.0996 46.9683 20.2384V20.2397Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M65.088 9.26208C65.088 9.88237 65.076 10.3915 65.0961 10.8992C65.1014 11.0265 65.2126 11.1497 65.2756 11.2757C65.3801 11.1819 65.5194 11.1069 65.5824 10.9903C65.7833 10.6206 65.9146 10.2093 66.1477 9.86361C66.6073 9.18304 67.3106 8.97404 68.0823 9.09729C68.2819 9.12945 68.5793 9.43222 68.5874 9.62112C68.6343 10.7559 68.6075 11.8933 68.6128 13.0307C68.6155 13.5184 68.3275 13.4487 67.9831 13.4353C67.2383 13.4058 66.4867 13.3991 65.7458 13.4607C65.2635 13.5009 65.0639 13.8305 65.0733 14.3543C65.1041 16.0263 65.0719 17.6983 65.0907 19.3702C65.0974 19.9865 64.8777 20.2745 64.2319 20.2531C63.3196 20.2236 62.4046 20.2317 61.4909 20.2531C61.1104 20.2611 60.9751 20.1111 60.9751 19.7467C60.9805 16.4925 60.9764 13.237 60.9805 9.98285C60.9805 9.58763 61.1117 9.26744 61.5833 9.26476C62.7154 9.2594 63.8474 9.26342 65.0867 9.26342L65.088 9.26208Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function UnkeyLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="94"
|
||||
height="24"
|
||||
viewBox="0 0 96 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.9012 20.0088C11.5775 20.0088 8.54297 17.1873 8.54297 12.3427V1.9082H11.3912V12.1564C11.3912 15.7499 12.9883 17.2405 16.9012 17.2405C20.8141 17.2405 22.4112 15.7499 22.4112 12.1564V1.9082H25.286V12.3427C25.286 17.1873 22.2515 20.0088 16.9012 20.0088ZM30.7083 19.7426H27.8335V6.51322H30.4688V10.6125H30.6551C31.0544 8.37651 32.8112 6.24703 36.1651 6.24703C39.8385 6.24703 41.6486 8.72256 41.6486 11.7837V19.7426H38.7738V12.5556C38.7738 10.0801 37.6558 8.82903 34.9141 8.82903C32.0127 8.82903 30.7083 10.3197 30.7083 13.1945V19.7426ZM47.085 19.7426H44.2102V1.9082H47.085V11.7571H50.8648L54.831 6.51322H58.1849L53.2072 12.8751L58.1583 19.7426H54.7777L50.8648 14.3391H47.085V19.7426ZM66.2537 20.0088C61.9149 20.0088 59.0667 17.5599 59.0667 13.1412C59.0667 9.01536 61.8883 6.24703 66.2005 6.24703C70.2997 6.24703 73.0947 8.50961 73.0947 12.529C73.0947 13.0081 73.0681 13.3808 72.9882 13.7801H61.7552C61.8616 16.3355 63.1127 17.693 66.1739 17.693C68.9422 17.693 70.0868 16.788 70.0868 15.2175V15.0045H72.9616V15.2441C72.9616 18.0657 70.1933 20.0088 66.2537 20.0088ZM66.1472 8.50961C63.2192 8.50961 61.9415 9.81392 61.7818 12.183H70.3796V12.1297C70.3796 9.68083 68.9688 8.50961 66.1472 8.50961ZM77.3773 24.2678H75.4874V21.6592H78.0694C79.2406 21.6592 79.7198 21.3398 80.1191 20.4347L80.4385 19.7426L73.9169 6.51322H77.1378L80.5183 13.5405L81.8226 16.7081H82.0356L83.2867 13.5139L86.401 6.51322H89.5686L82.6744 21.18C81.5831 23.5491 80.0924 24.2678 77.3773 24.2678Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1, Header3 } from "./primitives/Headers";
|
||||
import { motion } from "framer-motion";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import Spline from "@splinetool/react-spline";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
button?: {
|
||||
@@ -39,12 +43,27 @@ type DisplayOptionsProps = {
|
||||
|
||||
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header1 className="mb-4 border-b border-charcoal-800 pb-4">{title}</Header1>
|
||||
{message && <Header3>{message}</Header3>}
|
||||
<LinkButton to={button ? button.to : "/"} variant="primary/medium" className="mt-8">
|
||||
{button ? button.title : "Home"}
|
||||
</LinkButton>
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-[#16181C]">
|
||||
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
|
||||
<Header1>{title}</Header1>
|
||||
{message && <Paragraph>{message}</Paragraph>}
|
||||
<LinkButton
|
||||
to={button ? button.to : "/"}
|
||||
shortcut={{ modifiers: ["meta"], key: "g" }}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={HomeIcon}
|
||||
>
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<Spline scene="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Select, SelectItem } from "./primitives/Select";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -120,16 +121,18 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<FormError id={message.errorId}>{message.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<div className="flex w-full justify-end">
|
||||
<FormButtons
|
||||
className="m-0 w-max"
|
||||
confirmButton={
|
||||
<Button type="submit" variant="tertiary/medium">
|
||||
Send message
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium">
|
||||
Send message
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ function NextButton({ cursor }: { cursor?: string }) {
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"minimal/small"}
|
||||
TrailingIcon="arrow-right"
|
||||
TrailingIcon="chevron-right"
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
@@ -47,7 +47,7 @@ function PreviousButton({ cursor }: { cursor?: string }) {
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon="arrow-left"
|
||||
LeadingIcon="chevron-left"
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
className={cn(
|
||||
"flex items-center",
|
||||
|
||||
@@ -9,6 +9,10 @@ import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { TldrawLogo } from "~/assets/logos/TldrawLogo";
|
||||
import { UnkeyLogo } from "~/assets/logos/UnkeyLogo";
|
||||
import { LyftLogo } from "~/assets/logos/LyftLogo";
|
||||
import { MiddayLogo } from "~/assets/logos/MiddayLogo";
|
||||
|
||||
interface QuoteType {
|
||||
quote: string;
|
||||
@@ -72,11 +76,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex flex-col items-center gap-4 px-8">
|
||||
<Paragraph>Trusted by developers at</Paragraph>
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-charcoal-500 xl:justify-between xl:gap-0">
|
||||
<VerizonLogo />
|
||||
<ShopifyLogo />
|
||||
<ATAndTLogo />
|
||||
<LyftLogo className="w-11" />
|
||||
<UnkeyLogo />
|
||||
<MiddayLogo />
|
||||
<AppsmithLogo />
|
||||
<CalComLogo />
|
||||
<TldrawLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { InlineCode } from "./code/InlineCode";
|
||||
import {
|
||||
@@ -8,7 +10,31 @@ import {
|
||||
} from "./primitives/ClientTabs";
|
||||
import { ClipboardField } from "./primitives/ClipboardField";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
|
||||
type PackageManagerContextType = {
|
||||
activePackageManager: string;
|
||||
setActivePackageManager: (value: string) => void;
|
||||
};
|
||||
|
||||
const PackageManagerContext = createContext<PackageManagerContextType | undefined>(undefined);
|
||||
|
||||
export function PackageManagerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activePackageManager, setActivePackageManager] = useState("npm");
|
||||
|
||||
return (
|
||||
<PackageManagerContext.Provider value={{ activePackageManager, setActivePackageManager }}>
|
||||
{children}
|
||||
</PackageManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function usePackageManager() {
|
||||
const context = useContext(PackageManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePackageManager must be used within a PackageManagerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
|
||||
return (
|
||||
@@ -131,7 +157,6 @@ export function TriggerDevStep({ extra }: { extra?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger.dev version 3 setup commands
|
||||
const v3PackageTag = "latest";
|
||||
|
||||
function getApiUrlArg() {
|
||||
@@ -160,14 +185,19 @@ function getApiUrlArg() {
|
||||
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(" ");
|
||||
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
@@ -202,8 +232,14 @@ export function InitCommandV3() {
|
||||
}
|
||||
|
||||
export function TriggerDevStepV3() {
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
@@ -238,8 +274,14 @@ export function TriggerDevStepV3() {
|
||||
}
|
||||
|
||||
export function TriggerLoginStepV3() {
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { generateTwoRandomWords } from "~/utils/randomWords";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1 } from "../primitives/Headers";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
@@ -31,15 +29,17 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
leadingIconClassName="text-success"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
>
|
||||
Regenerate
|
||||
Regenerate…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{`Regenerate ${title} Environment Key`}</DialogHeader>
|
||||
<DialogHeader>{`Regenerate ${title.toUpperCase()} environment key`}</DialogHeader>
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
@@ -62,10 +62,10 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-y-5 py-4">
|
||||
<div className="flex flex-col items-center gap-y-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
{`Regenerating the keys for this environment will temporarily break any live Jobs in the
|
||||
${title} Environmentuntil the new API keys are set in the relevant environment variables.`}
|
||||
{`Regenerating the keys for this environment will temporarily break any live tasks in the
|
||||
${title} environment until the new API keys are set in the relevant environment variables.`}
|
||||
</Callout>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
@@ -73,7 +73,7 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
<Fieldset className="w-full">
|
||||
<InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
@@ -93,14 +93,14 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
variant={"primary/medium"}
|
||||
LeadingIcon={isSubmitting ? Spinner : undefined}
|
||||
disabled={confirmationText !== randomWord}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
cancelButton={<Button variant={"tertiary/small"}>Cancel</Button>}
|
||||
cancelButton={<Button variant={"tertiary/medium"}>Cancel</Button>}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
const { v3Enabled } = useFeatures();
|
||||
@@ -20,7 +19,7 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn("flex items-center justify-between border-b p-px transition")}>
|
||||
<div className={cn("flex h-10 items-center justify-between border-b p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
@@ -57,42 +56,7 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Help & Feedback
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<HelpAndFeedback />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
CalendarDaysIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
EnvelopeIcon,
|
||||
LightBulbIcon,
|
||||
SignalIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
export function HelpAndFeedback() {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger isOpen={isHelpMenuOpen} shortcut={{ key: "h" }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChatBubbleLeftEllipsisIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-green-500"
|
||||
activeIconColor="text-green-500"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Status"
|
||||
icon={SignalIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-green-500"
|
||||
activeIconColor="text-green-500"
|
||||
to="https://status.trigger.dev/"
|
||||
data-action="status"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Suggest a feature"
|
||||
icon={LightBulbIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to="https://feedback.trigger.dev/"
|
||||
data-action="suggest-a-feature"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
|
||||
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
|
||||
<div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={SlackIcon}
|
||||
data-action="join-our-slack"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-text-bright">Join our Slack…</span>
|
||||
<MenuCount count="PRO" />
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Join our Slack</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
|
||||
<Paragraph variant="base/bright">
|
||||
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
|
||||
support with the Trigger.dev team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Email us" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Send us an email to this address from your Trigger.dev account email
|
||||
address:
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
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 setup a Slack Connect channel and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Ask in our Discord"
|
||||
icon={DiscordIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Book a 15 min call"
|
||||
icon={CalendarDaysIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-rose-500"
|
||||
activeIconColor="text-rose-500"
|
||||
to="https://cal.com/team/triggerdotdev/founders-call"
|
||||
data-action="book-a-call"
|
||||
target="_blank"
|
||||
/>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Contact us…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowUpRightIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
CursorArrowRaysIcon,
|
||||
EnvelopeIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
LightBulbIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
SignalIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
@@ -52,6 +46,7 @@ import {
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3ConcurrencyPath,
|
||||
v3DeploymentsPath,
|
||||
@@ -64,17 +59,13 @@ import {
|
||||
v3TestPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { FreePlanUsage } from "../billing/v2/FreePlanUsage";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
@@ -83,12 +74,11 @@ import {
|
||||
PopoverCustomTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverSideMenuTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
|
||||
@@ -102,6 +92,34 @@ type SideMenuProps = {
|
||||
defaultValue?: FeedbackType;
|
||||
};
|
||||
|
||||
function V2Countdown() {
|
||||
const [days, setDays] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const targetDate = new Date("2025-01-31T00:00:00Z");
|
||||
|
||||
const calculateDays = () => {
|
||||
const now = new Date();
|
||||
const difference = targetDate.getTime() - now.getTime();
|
||||
return Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setDays(calculateDays());
|
||||
}, 1000 * 60 * 60); // Update every hour
|
||||
|
||||
setDays(calculateDays()); // Initial calculation
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Header2 className="flex-wrap gap-4 text-error">
|
||||
V2 goes offline in <span className="tabular-nums">{days}d</span>
|
||||
</Header2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
@@ -226,7 +244,8 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
<div className="m-2">
|
||||
{project.version === "V2" && (
|
||||
<div className="flex flex-col gap-3 rounded border border-success/50 bg-success/10 p-3">
|
||||
<div className="flex flex-col gap-3 rounded border border-error/50 bg-error/5 p-3">
|
||||
<V2Countdown />
|
||||
<Paragraph variant="small/bright">
|
||||
This is a v2 project. V2 will be deprecated on January 31, 2025.{" "}
|
||||
<TextLink
|
||||
@@ -261,167 +280,6 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
);
|
||||
}
|
||||
|
||||
function HelpAndFeedback() {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger isOpen={isHelpMenuOpen} shortcut={{ key: "h" }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChatBubbleLeftEllipsisIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-green-500"
|
||||
activeIconColor="text-green-500"
|
||||
to="https://trigger.dev/docs"
|
||||
data-action="documentation"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<SideMenuItem
|
||||
name="Status"
|
||||
icon={SignalIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-green-500"
|
||||
activeIconColor="text-green-500"
|
||||
to="https://status.trigger.dev/"
|
||||
data-action="status"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Suggest a feature"
|
||||
icon={LightBulbIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to="https://feedback.trigger.dev/"
|
||||
data-action="suggest-a-feature"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Changelog"
|
||||
icon="star"
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to="https://trigger.dev/changelog"
|
||||
data-action="changelog"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
<Paragraph className="pb-1 pl-1.5 pt-1.5 text-xs">Need help?</Paragraph>
|
||||
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
|
||||
<div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={SlackIcon}
|
||||
data-action="join-our-slack"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-text-bright">Join our Slack…</span>
|
||||
<MenuCount count="PRO" />
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Join our Slack</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={SlackIcon} className="h-10 w-10 min-w-[2.5rem]" />
|
||||
<Paragraph variant="base/bright">
|
||||
As a subscriber, you have access to a dedicated Slack channel for 1-to-1
|
||||
support with the Trigger.dev team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<div>
|
||||
<StepNumber stepNumber="1" title="Email us" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>
|
||||
Send us an email to this address from your Trigger.dev account email
|
||||
address:
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
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 setup a Slack Connect channel and say hello!
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Ask in our Discord"
|
||||
icon={DiscordIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Book a 15 min call"
|
||||
icon={CalendarDaysIcon}
|
||||
trailingIcon={ArrowUpRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
inactiveIconColor="text-rose-500"
|
||||
activeIconColor="text-rose-500"
|
||||
to="https://cal.com/team/triggerdotdev/founders-call"
|
||||
data-action="book-a-call"
|
||||
target="_blank"
|
||||
/>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Contact us…
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organizations,
|
||||
@@ -631,8 +489,6 @@ function V3ProjectSideMenu({
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { alertsEnabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"} />
|
||||
@@ -649,6 +505,13 @@ function V3ProjectSideMenu({
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3BatchesPath(organization, project)}
|
||||
data-action="batches"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
@@ -685,15 +548,13 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
{alertsEnabled && (
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Concurrency limits"
|
||||
icon={RectangleStackIcon}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
small: {
|
||||
size: "size-[1rem]",
|
||||
arrowHeadRight: "group-hover:translate-x-[3px]",
|
||||
arrowLineRight: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-[3px]",
|
||||
arrowLineLeft: "h-[1.5px] w-[7px] translate-x-1 top-[calc(50%-0.5px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
medium: {
|
||||
size: "size-[1.1rem]",
|
||||
arrowHeadRight: "group-hover:translate-x-[3px]",
|
||||
arrowLineRight: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-[-3px]",
|
||||
arrowLineLeft: "h-[1.5px] w-[9px] translate-x-1 top-[calc(50%-1px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
large: {
|
||||
size: "size-6",
|
||||
arrowHeadRight: "group-hover:translate-x-1",
|
||||
arrowLineRight: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-1",
|
||||
arrowLineLeft: "h-[2.3px] w-[12px] translate-x-[6px] top-[calc(50%-1px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
"extra-large": {
|
||||
size: "size-8",
|
||||
arrowHeadRight: "group-hover:translate-x-1",
|
||||
arrowLineRight: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
|
||||
arrowHeadLeft: "group-hover:translate-x-1",
|
||||
arrowLineLeft: "h-[3px] w-[16px] translate-x-[8px] top-[calc(50%-1.5px)]",
|
||||
arrowHeadTopRight:
|
||||
"-translate-x-0 transition group-hover:translate-x-[3px] group-hover:translate-y-[-3px]",
|
||||
},
|
||||
};
|
||||
|
||||
export const themes = {
|
||||
dark: {
|
||||
textStyle: "text-background-bright",
|
||||
arrowLine: "bg-background-bright",
|
||||
},
|
||||
dimmed: {
|
||||
textStyle: "text-text-dimmed",
|
||||
arrowLine: "bg-text-dimmed",
|
||||
},
|
||||
bright: {
|
||||
textStyle: "text-text-bright",
|
||||
arrowLine: "bg-text-bright",
|
||||
},
|
||||
primary: {
|
||||
textStyle: "text-text-dimmed group-hover:text-primary",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-primary",
|
||||
},
|
||||
blue: {
|
||||
textStyle: "text-text-dimmed group-hover:text-blue-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-blue-500",
|
||||
},
|
||||
rose: {
|
||||
textStyle: "text-text-dimmed group-hover:text-rose-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-rose-500",
|
||||
},
|
||||
amber: {
|
||||
textStyle: "text-text-dimmed group-hover:text-amber-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-amber-500",
|
||||
},
|
||||
apple: {
|
||||
textStyle: "text-text-dimmed group-hover:text-apple-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-apple-500",
|
||||
},
|
||||
lavender: {
|
||||
textStyle: "text-text-dimmed group-hover:text-lavender-500",
|
||||
arrowLine: "bg-text-dimmed group-hover:bg-lavender-500",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
type Theme = keyof typeof themes;
|
||||
|
||||
type AnimatingArrowProps = {
|
||||
className?: string;
|
||||
variant?: Variants;
|
||||
theme?: Theme;
|
||||
direction?: "right" | "left" | "topRight";
|
||||
};
|
||||
|
||||
export function AnimatingArrow({
|
||||
className,
|
||||
variant = "medium",
|
||||
theme = "dimmed",
|
||||
direction = "right",
|
||||
}: AnimatingArrowProps) {
|
||||
const variantStyles = variants[variant];
|
||||
const themeStyles = themes[theme];
|
||||
|
||||
return (
|
||||
<span className={cn("relative -mr-1 ml-1 flex", variantStyles.size, className)}>
|
||||
{direction === "topRight" && (
|
||||
<>
|
||||
<svg
|
||||
className={cn(
|
||||
"absolute top-[5px] transition duration-200 ease-in-out",
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
className={cn(
|
||||
"absolute top-[5px] transition duration-300 ease-in-out",
|
||||
themeStyles.textStyle,
|
||||
variantStyles.arrowHeadTopRight
|
||||
)}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M1 1H7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M7.5 7L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M1 7.5L7.5 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
{direction === "right" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
|
||||
variantStyles.arrowLineRight,
|
||||
themeStyles.arrowLine
|
||||
)}
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"absolute -translate-x-0.5 transition duration-300 ease-in-out",
|
||||
variantStyles.arrowHeadRight,
|
||||
variantStyles.size,
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{direction === "left" && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute rounded-full opacity-0 transition duration-300 ease-in-out group-hover:opacity-100",
|
||||
variantStyles.arrowLineLeft,
|
||||
themeStyles.arrowLine
|
||||
)}
|
||||
/>
|
||||
<ChevronLeftIcon
|
||||
className={cn(
|
||||
"absolute translate-x-0.5 transition duration-300 ease-in-out",
|
||||
variantStyles.arrowHeadLeft,
|
||||
variantStyles.size,
|
||||
themeStyles.textStyle
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,40 +41,53 @@ type Size = keyof typeof sizes;
|
||||
const theme = {
|
||||
primary: {
|
||||
textColor:
|
||||
"text-charcoal-900 group-hover:text-charcoal-900 transition group-disabled:text-charcoal-900",
|
||||
"text-charcoal-900 group-hover/button:text-charcoal-900 transition group-disabled/button:text-charcoal-900",
|
||||
button:
|
||||
"bg-primary group-hover:bg-apple-200 group-disabled:opacity-50 group-disabled:bg-primary group-disabled:pointer-events-none",
|
||||
"bg-primary group-hover/button:bg-apple-200 group-disabled/button:opacity-50 group-disabled/button:bg-primary group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
"border-black/40 text-charcoal-900 group-hover/button:border-black/60 group-hover/button:text-charcoal-900",
|
||||
icon: "text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-secondary group-hover:bg-charcoal-600 group-hover:border-charcoal-650 border border-charcoal-600 group-disabled:bg-secondary group-disabled:opacity-60 group-disabled:pointer-events-none",
|
||||
"bg-secondary group-hover/button:bg-charcoal-600 group-hover/button:border-charcoal-650 border border-charcoal-600 group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
|
||||
icon: "text-text-bright",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-tertiary group-hover:bg-charcoal-600 group-disabled:bg-tertiary group-disabled:opacity-60 group-disabled:pointer-events-none",
|
||||
"bg-tertiary group-hover/button:bg-charcoal-600 group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
|
||||
icon: "text-text-bright",
|
||||
},
|
||||
minimal: {
|
||||
textColor:
|
||||
"text-text-dimmed group-hover:text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
"text-text-dimmed group-hover/button:text-text-bright transition group-disabled/button:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-transparent group-hover:bg-tertiary disabled:opacity-50 group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
"bg-transparent group-hover/button:bg-tertiary disabled:opacity-50 group-disabled/button:bg-transparent group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
"border-dimmed/40 text-text-dimmed group-hover/button:text-text-bright/80 group-hover/button:border-dimmed/60",
|
||||
icon: "text-text-dimmed",
|
||||
},
|
||||
danger: {
|
||||
textColor:
|
||||
"text-text-bright group-hover:text-white transition group-disabled:text-text-bright/80",
|
||||
"text-text-bright group-hover/button:text-white transition group-disabled/button:text-text-bright/80",
|
||||
button:
|
||||
"bg-error group-hover:bg-rose-500 disabled:opacity-50 group-disabled:bg-error group-disabled:pointer-events-none",
|
||||
shortcut: "border-text-bright text-text-bright group-hover:border-bright/60",
|
||||
"bg-error group-hover/button:bg-rose-500 disabled:opacity-50 group-disabled/button:bg-error group-disabled/button:pointer-events-none",
|
||||
shortcut: "border-text-bright text-text-bright group-hover/button:border-bright/60",
|
||||
icon: "text-text-bright",
|
||||
},
|
||||
docs: {
|
||||
textColor: "text-blue-200/70 transition group-disabled/button:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-charcoal-700 border border-charcoal-600/50 shadow group-hover/button:bg-charcoal-650 group-disabled/button:bg-tertiary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
|
||||
icon: "text-blue-500",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,7 +97,7 @@ function createVariant(sizeName: Size, themeName: Theme) {
|
||||
return {
|
||||
textColor: theme[themeName].textColor,
|
||||
button: cn(sizes[sizeName].button, theme[themeName].button),
|
||||
icon: sizes[sizeName].icon,
|
||||
icon: cn(sizes[sizeName].icon, theme[themeName].icon),
|
||||
iconSpacing: sizes[sizeName].iconSpacing,
|
||||
shortcutVariant: sizes[sizeName].shortcutVariant,
|
||||
shortcut: cn(sizes[sizeName].shortcut, theme[themeName].shortcut),
|
||||
@@ -112,9 +125,14 @@ const variant = {
|
||||
"danger/medium": createVariant("medium", "danger"),
|
||||
"danger/large": createVariant("large", "danger"),
|
||||
"danger/extra-large": createVariant("extra-large", "danger"),
|
||||
"docs/small": createVariant("small", "docs"),
|
||||
"docs/medium": createVariant("medium", "docs"),
|
||||
"docs/large": createVariant("large", "docs"),
|
||||
"docs/extra-large": createVariant("extra-large", "docs"),
|
||||
"menu-item": {
|
||||
textColor: "text-text-bright px-1",
|
||||
button: "h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-750",
|
||||
button:
|
||||
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover/button:bg-charcoal-750",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
@@ -123,7 +141,7 @@ const variant = {
|
||||
"small-menu-item": {
|
||||
textColor: "text-text-bright",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750",
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-charcoal-750",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-1.5",
|
||||
shortcutVariant: undefined,
|
||||
@@ -132,7 +150,7 @@ const variant = {
|
||||
"small-menu-sub-item": {
|
||||
textColor: "text-text-dimmed",
|
||||
button:
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750 focus-custom",
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover/button:bg-charcoal-750 focus-custom",
|
||||
icon: undefined,
|
||||
iconSpacing: undefined,
|
||||
shortcutVariant: undefined,
|
||||
@@ -141,7 +159,7 @@ const variant = {
|
||||
};
|
||||
|
||||
const allVariants = {
|
||||
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus:outline-none group-disabled:opacity-75 group-disabled:pointer-events-none focus-custom",
|
||||
$all: "font-normal text-center font-sans justify-center items-center shrink-0 transition duration-150 rounded-[3px] select-none group-focus/button:outline-none group-disabled/button:opacity-75 group-disabled/button:pointer-events-none focus-custom",
|
||||
variant: variant,
|
||||
};
|
||||
|
||||
@@ -156,6 +174,7 @@ export type ButtonContentPropsType = {
|
||||
className?: string;
|
||||
shortcut?: ShortcutDefinition;
|
||||
variant: keyof typeof variant;
|
||||
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
|
||||
};
|
||||
|
||||
export function ButtonContent(props: ButtonContentPropsType) {
|
||||
@@ -192,13 +211,18 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
(typeof LeadingIcon === "string" ? (
|
||||
<NamedIcon
|
||||
name={LeadingIcon}
|
||||
className={cn(iconClassName, leadingIconClassName, "shrink-0 justify-start")}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
leadingIconClassName,
|
||||
"shrink-0 justify-start",
|
||||
variation.icon
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<LeadingIcon
|
||||
className={cn(
|
||||
iconClassName,
|
||||
textColorClassName,
|
||||
variation.icon,
|
||||
leadingIconClassName,
|
||||
"shrink-0 justify-start"
|
||||
)}
|
||||
@@ -214,29 +238,44 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
<>{text}</>
|
||||
))}
|
||||
|
||||
{TrailingIcon &&
|
||||
(typeof TrailingIcon === "string" ? (
|
||||
<NamedIcon
|
||||
name={TrailingIcon}
|
||||
className={cn(iconClassName, trailingIconClassName, "shrink-0 justify-end")}
|
||||
/>
|
||||
) : (
|
||||
<TrailingIcon
|
||||
className={cn(
|
||||
iconClassName,
|
||||
textColorClassName,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{shortcut && (
|
||||
{shortcut && props.shortcutPosition === "before-trailing-icon" && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{TrailingIcon &&
|
||||
(typeof TrailingIcon === "string" ? (
|
||||
<NamedIcon
|
||||
name={TrailingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end",
|
||||
variation.icon
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TrailingIcon
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{shortcut &&
|
||||
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -267,7 +306,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn("group outline-none focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
@@ -313,7 +352,7 @@ export const LinkButton = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group pointer-events-none cursor-default opacity-40 outline-none",
|
||||
"group/button pointer-events-none cursor-default opacity-40 outline-none",
|
||||
props.fullWidth ? "w-full" : ""
|
||||
)}
|
||||
>
|
||||
@@ -327,7 +366,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -342,7 +381,7 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -363,7 +402,7 @@ export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsT
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group/button outline-none", props.fullWidth ? "w-full" : "")}
|
||||
target={target}
|
||||
>
|
||||
{({ isActive, isPending }) => (
|
||||
|
||||
@@ -5,7 +5,11 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const ClientTabs = TabsPrimitive.Root;
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BellAlertIcon } from "@heroicons/react/20/solid";
|
||||
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CalendarDateTime, createCalendar } from "@internationalized/date";
|
||||
import { useDateField, useDateSegment } from "@react-aria/datepicker";
|
||||
import type { DateFieldState, DateSegment } from "@react-stately/datepicker";
|
||||
@@ -12,7 +12,7 @@ const variants = {
|
||||
small: {
|
||||
fieldStyles: "h-5 text-sm rounded-sm px-0.5",
|
||||
nowButtonVariant: "tertiary/small" as const,
|
||||
clearButtonVariant: "minimal/small" as const,
|
||||
clearButtonVariant: "tertiary/small" as const,
|
||||
},
|
||||
medium: {
|
||||
fieldStyles: "h-7 text-base rounded px-1",
|
||||
@@ -35,9 +35,12 @@ type DateFieldProps = {
|
||||
showNowButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
onValueChange?: (value: Date | undefined) => void;
|
||||
utc?: boolean;
|
||||
variant?: Variant;
|
||||
};
|
||||
|
||||
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
export function DateField({
|
||||
label,
|
||||
defaultValue,
|
||||
@@ -50,10 +53,11 @@ export function DateField({
|
||||
showGuide = false,
|
||||
showNowButton = false,
|
||||
showClearButton = false,
|
||||
utc = false,
|
||||
variant = "small",
|
||||
}: DateFieldProps) {
|
||||
const [value, setValue] = useState<undefined | CalendarDateTime>(
|
||||
utcDateToCalendarDate(defaultValue)
|
||||
utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue)
|
||||
);
|
||||
|
||||
const state = useDateFieldState({
|
||||
@@ -61,11 +65,11 @@ export function DateField({
|
||||
onChange: (value) => {
|
||||
if (value) {
|
||||
setValue(value);
|
||||
onValueChange?.(value.toDate("utc"));
|
||||
onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone));
|
||||
}
|
||||
},
|
||||
minValue: utcDateToCalendarDate(minValue),
|
||||
maxValue: utcDateToCalendarDate(maxValue),
|
||||
minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue),
|
||||
maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue),
|
||||
shouldForceLeadingZeros: true,
|
||||
granularity,
|
||||
locale: "en-US",
|
||||
@@ -78,7 +82,9 @@ export function DateField({
|
||||
useEffect(() => {
|
||||
if (state.value === undefined && defaultValue === undefined) return;
|
||||
|
||||
const calendarDate = utcDateToCalendarDate(defaultValue);
|
||||
const calendarDate = utc
|
||||
? utcDateToCalendarDate(defaultValue)
|
||||
: dateToCalendarDate(defaultValue);
|
||||
//unchanged
|
||||
if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) {
|
||||
return;
|
||||
@@ -134,23 +140,19 @@ export function DateField({
|
||||
<Button
|
||||
type="button"
|
||||
variant={variants[variant].nowButtonVariant}
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-text-dimmed group-hover:text-text-bright"
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setValue(utcDateToCalendarDate(new Date()));
|
||||
setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now));
|
||||
onValueChange?.(now);
|
||||
}}
|
||||
>
|
||||
<span className="text-text-dimmed transition group-hover:text-text-bright">Now</span>
|
||||
Now
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variants[variant].clearButtonVariant}
|
||||
LeadingIcon={"close"}
|
||||
leadingIconClassName="-mr-2"
|
||||
onClick={() => {
|
||||
setValue(undefined);
|
||||
onValueChange?.(undefined);
|
||||
@@ -181,7 +183,7 @@ function utcDateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCMonth() + 1,
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
@@ -190,6 +192,19 @@ function utcDateToCalendarDate(date?: Date) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function dateToCalendarDate(date?: Date) {
|
||||
return date
|
||||
? new CalendarDateTime(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getSeconds()
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type DateSegmentProps = {
|
||||
segment: DateSegment;
|
||||
state: DateFieldState;
|
||||
|
||||
@@ -34,6 +34,7 @@ type DetailCellProps = {
|
||||
description?: string | React.ReactNode;
|
||||
className?: string;
|
||||
variant?: keyof typeof variations;
|
||||
boxClassName?: string;
|
||||
};
|
||||
|
||||
export function DetailCell({
|
||||
@@ -45,6 +46,7 @@ export function DetailCell({
|
||||
description,
|
||||
className,
|
||||
variant = "small",
|
||||
boxClassName,
|
||||
}: DetailCellProps) {
|
||||
const variation = variations[variant];
|
||||
|
||||
@@ -53,6 +55,7 @@ export function DetailCell({
|
||||
<IconInBox
|
||||
icon={leadingIcon}
|
||||
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
|
||||
boxClassName={boxClassName}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Paragraph
|
||||
|
||||
@@ -82,7 +82,7 @@ DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2", className)}
|
||||
className={cn("flex justify-between border-t border-grid-bright pt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -106,7 +106,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground pt-4 text-sm", className)}
|
||||
className={cn("pt-2 text-base text-text-dimmed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -10,7 +10,12 @@ export function FormButtons({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center justify-between", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between border-t border-grid-bright pt-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,13 @@ import { Paragraph } from "./Paragraph";
|
||||
|
||||
const variants = {
|
||||
info: {
|
||||
panelStyle: "border-grid-bright bg-background-bright",
|
||||
panelStyle: "border-grid-bright bg-background-bright rounded-md border p-4 gap-3",
|
||||
},
|
||||
upgrade: {
|
||||
panelStyle: "border-indigo-400/20 bg-indigo-800/10",
|
||||
panelStyle: "border-indigo-400/20 bg-indigo-800/10 rounded-md border p-4 gap-3",
|
||||
},
|
||||
minimal: {
|
||||
panelStyle: "max-w-full w-full py-3 px-3 gap-2",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,7 +46,7 @@ export function InfoPanel({
|
||||
className={cn(
|
||||
variantStyle.panelStyle,
|
||||
title ? "flex-col" : "",
|
||||
"flex h-fit items-start gap-3 rounded-md border p-4",
|
||||
"flex h-fit items-start",
|
||||
panelClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -20,15 +20,15 @@ export function PaginationControls({
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1" aria-label="Pagination">
|
||||
<nav className="flex items-center gap-0.5" aria-label="Pagination">
|
||||
<LinkDisabled
|
||||
to={pageUrl(location, currentPage - 1)}
|
||||
className={currentPage > 1 ? "group" : ""}
|
||||
disabled={currentPage === 1}
|
||||
disabledClassName="opacity-30 cursor-default"
|
||||
>
|
||||
<ButtonContent variant="minimal/medium" LeadingIcon={ChevronLeftIcon}>
|
||||
Previous
|
||||
<ButtonContent variant="minimal/small" LeadingIcon={ChevronLeftIcon}>
|
||||
Prev
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function PaginationControls({
|
||||
disabled={currentPage === totalPages}
|
||||
disabledClassName="opacity-30 cursor-default"
|
||||
>
|
||||
<ButtonContent variant="minimal/medium" TrailingIcon={ChevronRightIcon}>
|
||||
<ButtonContent variant="minimal/small" TrailingIcon={ChevronRightIcon}>
|
||||
Next
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
@@ -61,9 +61,9 @@ function pageUrl(location: ReturnType<typeof useLocation>, page: number): string
|
||||
}
|
||||
|
||||
const baseClass =
|
||||
"flex items-center justify-center border border-transparent h-8 w-8 text-xs font-medium transition text-text-dimmed rounded-sm";
|
||||
"flex items-center justify-center border border-transparent min-w-6 h-6 px-1 text-xs font-medium transition text-text-dimmed rounded-sm";
|
||||
const unselectedClass = "hover:bg-tertiary hover:text-text-bright";
|
||||
const selectedClass = "border-text-dimmed text-text-bright hover:bg-tertiary";
|
||||
const selectedClass = "border-charcoal-600 bg-tertiary text-text-bright hover:bg-charcoal-600/50";
|
||||
|
||||
function PageLinkComponent({
|
||||
page,
|
||||
|
||||
@@ -190,11 +190,11 @@ function PopoverVerticalEllipseTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded px-1.5 py-1.5 text-text-dimmed transition focus-custom hover:bg-charcoal-750 hover:text-text-bright",
|
||||
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon className={cn("h-5 w-5 transition group-hover:text-text-bright")} />
|
||||
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
|
||||
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
@@ -482,7 +482,11 @@ export function SelectItem({
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
|
||||
shortcut={shortcut}
|
||||
variant={"small"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectItem>
|
||||
@@ -613,7 +617,7 @@ export function SelectPopover({
|
||||
"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(520px,var(--popover-available-height))]",
|
||||
"max-h-[min(600px,var(--popover-available-height))]",
|
||||
"origin-[var(--popover-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -26,8 +26,8 @@ export function Spinner({
|
||||
foreground: "#3C4B62",
|
||||
},
|
||||
dark: {
|
||||
background: "#15171A",
|
||||
foreground: "#272A2E",
|
||||
background: "rgba(18, 19, 23, 0.35)",
|
||||
foreground: "#1A1B1F",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@ const variations = {
|
||||
},
|
||||
small: {
|
||||
container:
|
||||
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom",
|
||||
"flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer",
|
||||
root: "h-3 w-6",
|
||||
thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition",
|
||||
text: "text-xs",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
"overflow-x-auto whitespace-nowrap border-t border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -41,7 +41,7 @@ export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 z-10 divide-y divide-grid-dimmed rounded-t-md bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
"sticky top-0 z-10 bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -59,10 +59,7 @@ type TableBodyProps = {
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className, children }, ref) => {
|
||||
return (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("relative divide-y divide-grid-dimmed overflow-y-auto", className)}
|
||||
>
|
||||
<tbody ref={ref} className={cn("relative overflow-y-auto", className)}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
@@ -73,12 +70,21 @@ type TableRowProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, disabled, children }, ref) => {
|
||||
({ className, disabled, isSelected, children }, ref) => {
|
||||
return (
|
||||
<tr ref={ref} className={cn(disabled && "opacity-50", "group/table-row w-full", className)}>
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group/table-row relative w-full after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
disabled && "opacity-50",
|
||||
isSelected && isSelectedStyle,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
@@ -114,7 +120,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
ref={ref}
|
||||
scope="col"
|
||||
className={cn(
|
||||
"px-3 py-2 align-middle text-xxs font-normal uppercase tracking-wider text-text-dimmed",
|
||||
"px-3 py-2.5 pb-3 align-middle text-sm font-medium text-text-bright",
|
||||
alignmentClassName,
|
||||
className
|
||||
)}
|
||||
@@ -142,16 +148,22 @@ type TableCellProps = TableCellBasicProps & {
|
||||
isSticky?: boolean;
|
||||
actionClassName?: string;
|
||||
rowHoverStyle?: keyof typeof rowHoverStyles;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
const rowHoverStyles = {
|
||||
default: "group-hover/table-row:bg-charcoal-800",
|
||||
dimmed: "group-hover/table-row:bg-charcoal-850",
|
||||
bright: "group-hover/table-row:bg-charcoal-750",
|
||||
default:
|
||||
"group-hover/table-row:bg-charcoal-800 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-750 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-750 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
|
||||
dimmed:
|
||||
"group-hover/table-row:bg-charcoal-850 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-800 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-800 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
|
||||
bright:
|
||||
"group-hover/table-row:bg-charcoal-750 group-hover/table-row:before:absolute group-hover/table-row:before:bg-charcoal-700 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:left-0 group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:after:absolute group-hover/table-row:after:bg-charcoal-700 group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3",
|
||||
};
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 w-[2.8rem] min-w-[2.8rem] bg-background-dimmed before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem]";
|
||||
"sticky right-0 bg-background-dimmed group-hover/table-row:bg-charcoal-750 w-[--sticky-width] [&:has(.group-hover\\/table-row\\:block)]:w-auto";
|
||||
|
||||
const isSelectedStyle = "bg-charcoal-750 group-hover:bg-charcoal-750";
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
(
|
||||
@@ -166,6 +178,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
hasAction = false,
|
||||
isSticky = false,
|
||||
rowHoverStyle = "default",
|
||||
isSelected,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -192,11 +205,12 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-charcoal-400 transition-colors",
|
||||
"text-xs text-charcoal-400",
|
||||
to || onClick || hasAction ? "cursor-pointer" : "px-3 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
rowHoverStyles[rowHoverStyle],
|
||||
isSelected && isSelectedStyle,
|
||||
!isSelected && rowHoverStyles[rowHoverStyle],
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
@@ -237,7 +251,7 @@ export const TableCellChevron = forwardRef<
|
||||
alignment="right"
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="h-4 w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
<ChevronRightIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</TableCell>
|
||||
);
|
||||
});
|
||||
@@ -246,33 +260,90 @@ export const TableCellMenu = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
isSticky?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
visibleButtons?: ReactNode;
|
||||
hiddenButtons?: ReactNode;
|
||||
popoverContent?: ReactNode;
|
||||
children?: ReactNode;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
>(({ className, children, isSticky, onClick }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<TableCell
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
hasAction={true}
|
||||
>
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
|
||||
<PopoverContent
|
||||
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
);
|
||||
});
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
isSticky,
|
||||
onClick,
|
||||
visibleButtons,
|
||||
hiddenButtons,
|
||||
popoverContent,
|
||||
children,
|
||||
isSelected,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
className={className}
|
||||
isSticky={isSticky}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
hasAction={true}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<div className="relative h-full p-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 rounded-[0.25rem] bg-background-dimmed p-0.5 group-hover/table-row:bg-background-bright group-hover/table-row:ring-1 group-hover/table-row:ring-grid-bright",
|
||||
isSelected && isSelectedStyle,
|
||||
isSelected &&
|
||||
"group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/50"
|
||||
)}
|
||||
>
|
||||
{/* Hidden buttons that show on hover */}
|
||||
{hiddenButtons && (
|
||||
<div className="hidden pr-0.5 group-hover/table-row:block group-hover/table-row:border-r group-hover/table-row:border-grid-dimmed">
|
||||
{hiddenButtons}
|
||||
</div>
|
||||
)}
|
||||
{/* Always visible buttons */}
|
||||
{visibleButtons}
|
||||
{/* Always visible popover with ellipsis trigger */}
|
||||
{popoverContent && (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverVerticalEllipseTrigger
|
||||
isOpen={isOpen}
|
||||
className="duration-0 group-hover/table-row:text-text-bright"
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[10rem] max-w-[20rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{popoverContent}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{/* Optionally pass in children to render in a popover */}
|
||||
{!visibleButtons && !hiddenButtons && !popoverContent && (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)}>
|
||||
<PopoverVerticalEllipseTrigger isOpen={isOpen} />
|
||||
<PopoverContent
|
||||
className="w-fit max-w-[10rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="end"
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type TableBlankRowProps = {
|
||||
className?: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -39,23 +40,27 @@ export function ToastUI({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`self-end rounded-md border border-grid-bright bg-background-dimmed`}
|
||||
className={cn(
|
||||
"self-end rounded-md border border-grid-bright bg-background-dimmed",
|
||||
variant === "success" && "border-success",
|
||||
variant === "error" && "border-error"
|
||||
)}
|
||||
style={{
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
|
||||
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
|
||||
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
|
||||
)}
|
||||
<Paragraph className="py-1 text-text-dimmed">{message}</Paragraph>
|
||||
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
|
||||
<button
|
||||
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +214,7 @@ export function AbsoluteTimeFrame({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
utc
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -227,6 +228,7 @@ export function AbsoluteTimeFrame({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
utc
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import {
|
||||
allBatchStatuses,
|
||||
BatchStatusCombo,
|
||||
batchStatusTitle,
|
||||
descriptionForBatchStatus,
|
||||
} from "./BatchStatus";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
|
||||
export const BatchStatus = z.enum(allBatchStatuses);
|
||||
|
||||
export const BatchListFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
environments: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
BatchStatus.array().optional()
|
||||
),
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
id: z.string().optional(),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type BatchListFilters = z.infer<typeof BatchListFilters>;
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type BatchFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
export function BatchFilters(props: BatchFiltersProps) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("id") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<AppliedFilters {...props} />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
name: "statuses",
|
||||
title: "Status",
|
||||
icon: (
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<div className="size-3 rounded-full border-2 border-text-dimmed" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
function FilterMenu(props: BatchFiltersProps) {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
variant={"minimal/small"}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={"Filter runs"}
|
||||
>
|
||||
Filter
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
|
||||
{(search, setSearch) => (
|
||||
<Menu
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedCustomDateRangeFilter />
|
||||
<AppliedBatchIdFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
} & BatchFiltersProps;
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "statuses":
|
||||
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "environments":
|
||||
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "daterange":
|
||||
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "batch":
|
||||
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) => {
|
||||
if (item.name === "daterange") return false;
|
||||
return item.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{type.title}
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allBatchStatuses.map((status) => ({
|
||||
title: batchStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function StatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ statuses: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<BatchStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForBatchStatus(item.value)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedStatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const statuses = values("statuses");
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
value={appliedSummary(
|
||||
statuses.map((v) => batchStatusTitle(v as BatchTaskRunStatus))
|
||||
)}
|
||||
onRemove={() => del(["statuses", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const batchIdValue = value("id");
|
||||
|
||||
const [batchId, setBatchId] = useState(batchIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
id: batchId === "" ? undefined : batchId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [batchId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (batchId) {
|
||||
if (!batchId.startsWith("batch_")) {
|
||||
error = "Batch IDs start with 'batch_'";
|
||||
} else if (batchId.length !== 27) {
|
||||
error = "Batch IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Batch ID</Label>
|
||||
<Input
|
||||
placeholder="batch_"
|
||||
value={batchId ?? ""}
|
||||
onChange={(e) => setBatchId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !batchId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedBatchIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("id") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const batchId = value("id");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<BatchIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Batch ID"
|
||||
value={batchId}
|
||||
onRemove={() => del(["id", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allBatchStatuses = ["PENDING", "COMPLETED"] as const satisfies Readonly<
|
||||
Array<BatchTaskRunStatus>
|
||||
>;
|
||||
|
||||
const descriptions: Record<BatchTaskRunStatus, string> = {
|
||||
PENDING: "The batch has child runs that have not yet completed.",
|
||||
COMPLETED: "All the batch child runs have finished.",
|
||||
};
|
||||
|
||||
export function descriptionForBatchStatus(status: BatchTaskRunStatus): string {
|
||||
return descriptions[status];
|
||||
}
|
||||
|
||||
export function BatchStatusCombo({
|
||||
status,
|
||||
className,
|
||||
iconClassName,
|
||||
}: {
|
||||
status: BatchTaskRunStatus;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<BatchStatusIcon status={status} className={cn("h-4 w-4", iconClassName)} />
|
||||
<BatchStatusLabel status={status} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) {
|
||||
return <span className={batchStatusColor(status)}>{batchStatusTitle(status)}</span>;
|
||||
}
|
||||
|
||||
export function BatchStatusIcon({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: BatchTaskRunStatus;
|
||||
className: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return <Spinner className={cn(batchStatusColor(status), className)} />;
|
||||
case "COMPLETED":
|
||||
return <CheckCircleIcon className={cn(batchStatusColor(status), className)} />;
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function batchStatusColor(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "text-pending";
|
||||
case "COMPLETED":
|
||||
return "text-success";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { StopCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { NoSymbolIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
@@ -7,6 +9,8 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
type CancelRunDialogProps = {
|
||||
runFriendlyId: string;
|
||||
@@ -22,24 +26,33 @@ export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialog
|
||||
return (
|
||||
<DialogContent key="cancel">
|
||||
<DialogHeader>Cancel this run?</DialogHeader>
|
||||
<DialogDescription>
|
||||
Canceling a run will stop execution, along with any executing subtasks.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="danger/small"
|
||||
LeadingIcon={isLoading ? "spinner-white" : StopCircleIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : "Cancel run"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
Canceling a run will stop execution, along with any executing subtasks.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="danger/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : NoSymbolIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Canceling..." : "Cancel run"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Close</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
type CheckBatchCompletionDialogProps = {
|
||||
batchId: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function CheckBatchCompletionDialog({
|
||||
batchId,
|
||||
redirectPath,
|
||||
}: CheckBatchCompletionDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/batches/${batchId}/check-completion`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="check-completion">
|
||||
<DialogHeader>Try and resume batch</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
In rare cases, parent runs don't continue after child runs have completed.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If this doesn't help, please get in touch. We are working on a permanent fix for this.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form action={`/resources/batches/${batchId}/check-completion`} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : undefined}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Attempting resume..." : "Attempt resume"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -118,3 +118,34 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PENDING and CANCELED are not used so are ommited from the UI
|
||||
export const deploymentStatuses: WorkerDeploymentStatus[] = [
|
||||
"BUILDING",
|
||||
"DEPLOYING",
|
||||
"DEPLOYED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
];
|
||||
|
||||
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "The deployment is queued and waiting to be processed.";
|
||||
case "BUILDING":
|
||||
return "The code is being built and prepared for deployment.";
|
||||
case "DEPLOYING":
|
||||
return "The deployment is in progress and tasks are being indexed.";
|
||||
case "DEPLOYED":
|
||||
return "The deployment has completed successfully.";
|
||||
case "CANCELED":
|
||||
return "The deployment was manually canceled.";
|
||||
case "FAILED":
|
||||
return "The deployment encountered an error and could not complete.";
|
||||
case "TIMED_OUT":
|
||||
return "The deployment exceeded the maximum allowed time and was stopped.";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +129,12 @@ function ReplayForm({
|
||||
defaultValue={environment.id}
|
||||
items={environments}
|
||||
dropdownIcon
|
||||
variant="tertiary/small"
|
||||
variant="tertiary/medium"
|
||||
className="w-fit pl-1"
|
||||
text={(value) => {
|
||||
const env = environments.find((env) => env.id === value)!;
|
||||
return (
|
||||
<div className="flex items-center pr-2">
|
||||
<div className="flex items-center pl-1 pr-2">
|
||||
<EnvironmentLabel environment={env} userName={env.userName} />
|
||||
</div>
|
||||
);
|
||||
@@ -152,11 +152,11 @@ function ReplayForm({
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-grid-dimmed pt-3.5">
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/small">Cancel</Button>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isSubmitting ? ButtonSpinner : undefined}
|
||||
disabled={isSubmitting}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter", enabledOnInputElements: true }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
@@ -33,6 +34,9 @@ export function RetryDeploymentIndexingDialog({
|
||||
any errors and re-deploy.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/retry-indexing`}
|
||||
method="post"
|
||||
@@ -41,7 +45,7 @@ export function RetryDeploymentIndexingDialog({
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/small"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
@@ -33,6 +34,9 @@ export function RollbackDeploymentDialog({
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
@@ -41,7 +45,7 @@ export function RollbackDeploymentDialog({
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/small"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CalendarIcon,
|
||||
ClockIcon,
|
||||
CpuChipIcon,
|
||||
InboxStackIcon,
|
||||
FingerPrintIcon,
|
||||
Squares2X2Icon,
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useFetcher } from "@remix-run/react";
|
||||
import type {
|
||||
RuntimeEnvironment,
|
||||
TaskTriggerSource,
|
||||
TaskRunStatus,
|
||||
BulkActionType,
|
||||
RuntimeEnvironment,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import { ListChecks, ListFilterIcon } from "lucide-react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import type { ReactNode } from "react";
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
@@ -33,6 +37,8 @@ import {
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -40,22 +46,29 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
filterableTaskRunStatuses,
|
||||
descriptionForTaskRunStatus,
|
||||
filterableTaskRunStatuses,
|
||||
runStatusTitle,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { matchSorter } from "match-sorter";
|
||||
|
||||
export const TaskAttemptStatus = z.enum(allTaskRunStatuses);
|
||||
|
||||
@@ -86,6 +99,10 @@ export const TaskRunListSearchFilters = z.object({
|
||||
bulkId: z.string().optional(),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
rootOnly: z.coerce.boolean().optional(),
|
||||
batchId: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
@@ -102,6 +119,7 @@ type RunFiltersProps = {
|
||||
type: BulkActionType;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
rootOnlyDefault: boolean;
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
@@ -114,15 +132,24 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("bulkId") ||
|
||||
searchParams.has("tags");
|
||||
searchParams.has("tags") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to") ||
|
||||
searchParams.has("batchId") ||
|
||||
searchParams.has("runId") ||
|
||||
searchParams.has("scheduleId");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<RootOnlyToggle defaultValue={props.rootOnlyDefault} />
|
||||
<AppliedFilters {...props} />
|
||||
{hasFilters && (
|
||||
<Form>
|
||||
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
|
||||
<Form className="h-6">
|
||||
{searchParams.has("rootOnly") && (
|
||||
<input type="hidden" name="rootOnly" value={searchParams.get("rootOnly") as string} />
|
||||
)}
|
||||
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
</Form>
|
||||
@@ -145,7 +172,11 @@ const filterTypes = [
|
||||
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
|
||||
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <InboxStackIcon className="size-4" /> },
|
||||
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "run", title: "Run ID", icon: <FingerPrintIcon className="size-4" /> },
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <ListChecks className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
@@ -186,34 +217,6 @@ function FilterMenu(props: RunFiltersProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children(searchValue, setSearchValue)}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -222,6 +225,10 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru
|
||||
<AppliedTaskFilter possibleTasks={possibleTasks} />
|
||||
<AppliedTagsFilter />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedCustomDateRangeFilter />
|
||||
<AppliedRunIdFilter />
|
||||
<AppliedBatchIdFilter />
|
||||
<AppliedScheduleIdFilter />
|
||||
<AppliedBulkActionsFilter bulkActions={bulkActions} />
|
||||
</>
|
||||
);
|
||||
@@ -246,19 +253,28 @@ function Menu(props: MenuProps) {
|
||||
case "tasks":
|
||||
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "daterange":
|
||||
return <CustomDateRangeDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "bulk":
|
||||
return <BulkActionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "tags":
|
||||
return <TagsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "run":
|
||||
return <RunIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "batch":
|
||||
return <BatchIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "schedule":
|
||||
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
return filterTypes.filter((item) => {
|
||||
if (item.name === "daterange") return false;
|
||||
return item.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
@@ -340,7 +356,7 @@ function StatusDropdown({
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<TooltipContent side="right" sideOffset={50}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForTaskRunStatus(item.value)}
|
||||
</Paragraph>
|
||||
@@ -384,100 +400,6 @@ function AppliedStatusFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environments: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleEnvironments.filter((item) => {
|
||||
const title = environmentTitle(item, item.userName);
|
||||
return title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleEnvironments]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedEnvironmentFilter({
|
||||
possibleEnvironments,
|
||||
}: Pick<RunFiltersProps, "possibleEnvironments">) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("environments").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
values("environments").map((v) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === v);
|
||||
return environment ? environmentTitle(environment, environment.userName) : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["environments", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
@@ -524,7 +446,9 @@ function TasksDropdown({
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
value={item.slug}
|
||||
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
@@ -685,7 +609,7 @@ function TagsDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
const fetcher = useFetcher<typeof tagsLoader>();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -780,62 +704,34 @@ function AppliedTagsFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "All periods",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "5 mins ago",
|
||||
value: "5m",
|
||||
},
|
||||
{
|
||||
label: "15 mins ago",
|
||||
value: "15m",
|
||||
},
|
||||
{
|
||||
label: "30 mins ago",
|
||||
value: "30m",
|
||||
},
|
||||
{
|
||||
label: "1 hour ago",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "3 hours ago",
|
||||
value: "3h",
|
||||
},
|
||||
{
|
||||
label: "6 hours ago",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "1 day ago",
|
||||
value: "1d",
|
||||
},
|
||||
{
|
||||
label: "3 days ago",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "7 days ago",
|
||||
value: "7d",
|
||||
},
|
||||
{
|
||||
label: "10 days ago",
|
||||
value: "10d",
|
||||
},
|
||||
{
|
||||
label: "14 days ago",
|
||||
value: "14d",
|
||||
},
|
||||
{
|
||||
label: "30 days ago",
|
||||
value: "30d",
|
||||
},
|
||||
];
|
||||
function RootOnlyToggle({ defaultValue }: { defaultValue: boolean }) {
|
||||
const { value, values, replace } = useSearchParams();
|
||||
const searchValue = value("rootOnly");
|
||||
const rootOnly = searchValue !== undefined ? searchValue === "true" : defaultValue;
|
||||
|
||||
function CreatedDropdown({
|
||||
const batchId = value("batchId");
|
||||
const runId = value("runId");
|
||||
const scheduleId = value("scheduleId");
|
||||
const tasks = values("tasks");
|
||||
|
||||
const disabled = !!batchId || !!runId || !!scheduleId || tasks.length > 0;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
variant="small"
|
||||
label="Root only"
|
||||
checked={disabled ? false : rootOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
replace({
|
||||
rootOnly: checked ? "true" : "false",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RunIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
@@ -846,25 +742,34 @@ function CreatedDropdown({
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const runIdValue = value("runId");
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
const [runId, setRunId] = useState(runIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
if (newValue === "all") {
|
||||
if (!value) return;
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
runId: runId === "" ? undefined : runId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [runId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (runId) {
|
||||
if (!runId.startsWith("run_")) {
|
||||
error = "Run IDs start with 'run_'";
|
||||
} else if (runId.length !== 25) {
|
||||
error = "Run IDs are 25 characters long";
|
||||
}
|
||||
|
||||
replace({ period: newValue, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return timePeriods.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
@@ -876,39 +781,63 @@ function CreatedDropdown({
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Run ID</Label>
|
||||
<Input
|
||||
placeholder="run_"
|
||||
value={runId ?? ""}
|
||||
onChange={(e) => setRunId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[27ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !runId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedPeriodFilter() {
|
||||
function AppliedRunIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("period") === undefined || value("period") === "all") {
|
||||
if (value("runId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runId = value("runId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CreatedDropdown
|
||||
<RunIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
|
||||
}
|
||||
onRemove={() => del(["period", "cursor", "direction"])}
|
||||
label="Run ID"
|
||||
value={runId}
|
||||
onRemove={() => del(["runId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
@@ -920,14 +849,238 @@ function AppliedPeriodFilter() {
|
||||
);
|
||||
}
|
||||
|
||||
function appliedSummary(values: string[], maxValues = 3) {
|
||||
if (values.length === 0) {
|
||||
function BatchIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const batchIdValue = value("batchId");
|
||||
|
||||
const [batchId, setBatchId] = useState(batchIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
batchId: batchId === "" ? undefined : batchId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [batchId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (batchId) {
|
||||
if (!batchId.startsWith("batch_")) {
|
||||
error = "Batch IDs start with 'batch_'";
|
||||
} else if (batchId.length !== 27) {
|
||||
error = "Batch IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Batch ID</Label>
|
||||
<Input
|
||||
placeholder="batch_"
|
||||
value={batchId ?? ""}
|
||||
onChange={(e) => setBatchId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !batchId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedBatchIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("batchId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values.length > maxValues) {
|
||||
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
|
||||
const batchId = value("batchId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<BatchIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Batch ID"
|
||||
value={batchId}
|
||||
onRemove={() => del(["batchId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const scheduleIdValue = value("scheduleId");
|
||||
|
||||
const [scheduleId, setScheduleId] = useState(scheduleIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [scheduleId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (scheduleId) {
|
||||
if (!scheduleId.startsWith("sched")) {
|
||||
error = "Schedule IDs start with 'sched_'";
|
||||
} else if (scheduleId.length !== 27) {
|
||||
error = "Schedule IDs are 27 characters long";
|
||||
}
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Schedule ID</Label>
|
||||
<Input
|
||||
placeholder="sched_"
|
||||
value={scheduleId ?? ""}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !scheduleId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedScheduleIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("scheduleId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleId = value("scheduleId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ScheduleIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Schedule ID"
|
||||
value={scheduleId}
|
||||
onRemove={() => del(["scheduleId", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
import { TraceSpan } from "~/utils/taskEvent";
|
||||
import { SpanLink } from "~/v3/eventRepository.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
|
||||
import { RunTag } from "./RunTag";
|
||||
import { TaskRunStatusCombo } from "./TaskRunStatus";
|
||||
@@ -121,7 +121,7 @@ export function RunInspector({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
@@ -479,6 +479,7 @@ function RunTimeline({ run }: { run: RawRun }) {
|
||||
const updatedAt = new Date(run.updatedAt);
|
||||
|
||||
const isFinished = isFinalRunStatus(run.status);
|
||||
const isError = isFailedRunStatus(run.status);
|
||||
|
||||
return (
|
||||
<div className="min-w-fit max-w-80">
|
||||
@@ -535,7 +536,7 @@ function RunTimeline({ run }: { run: RawRun }) {
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={<DateTimeAccurate date={updatedAt} />}
|
||||
state="complete"
|
||||
state={isError ? "error" : "complete"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import type { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { DateField } from "~/components/primitives/DateField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
|
||||
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children(searchValue, setSearchValue)}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environments: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleEnvironments.filter((item) => {
|
||||
const title = environmentTitle(item, item.userName);
|
||||
return title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleEnvironments]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedEnvironmentFilter({
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("environments").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
values("environments").map((v) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === v);
|
||||
return environment ? environmentTitle(environment, environment.userName) : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["environments", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "Last 5 mins",
|
||||
value: "5m",
|
||||
},
|
||||
{
|
||||
label: "Last 30 mins",
|
||||
value: "30m",
|
||||
},
|
||||
{
|
||||
label: "Last 1 hour",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "Last 6 hours",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "Last 1 day",
|
||||
value: "1d",
|
||||
},
|
||||
{
|
||||
label: "Last 3 days",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
value: "7d",
|
||||
},
|
||||
{
|
||||
label: "Last 14 days",
|
||||
value: "14d",
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
value: "30d",
|
||||
},
|
||||
{
|
||||
label: "All periods",
|
||||
value: "all",
|
||||
},
|
||||
];
|
||||
|
||||
export function CreatedAtDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
setFilterType,
|
||||
hideCustomRange,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
setFilterType?: (type: "daterange" | undefined) => void;
|
||||
hideCustomRange?: boolean;
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
|
||||
const from = value("from");
|
||||
const to = value("to");
|
||||
const period = value("period");
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
clearSearchValue();
|
||||
if (newValue === "all") {
|
||||
if (!period && !from && !to) return;
|
||||
|
||||
replace({
|
||||
period: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === "custom") {
|
||||
setFilterType?.("daterange");
|
||||
return;
|
||||
}
|
||||
|
||||
replace({
|
||||
period: newValue,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return timePeriods.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider
|
||||
value={from || to ? "custom" : period ?? "all"}
|
||||
setValue={handleChange}
|
||||
virtualFocus={true}
|
||||
>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!hideCustomRange ? (
|
||||
<SelectItem value="custom" hideOnClick={false}>
|
||||
Custom date range
|
||||
</SelectItem>
|
||||
) : null}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedPeriodFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("period") === undefined || value("period") === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CreatedAtDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
|
||||
}
|
||||
onRemove={() => del(["period", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
hideCustomRange
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomDateRangeDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const fromSearch = dateFromString(value("from"));
|
||||
const toSearch = dateFromString(value("to"));
|
||||
const [from, setFrom] = useState(fromSearch);
|
||||
const [to, setTo] = useState(toSearch);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
period: undefined,
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
from: from?.getTime().toString(),
|
||||
to: to?.getTime().toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [from, to, replace]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>From (local time)</Label>
|
||||
<DateField
|
||||
label="From time"
|
||||
defaultValue={from}
|
||||
onValueChange={setFrom}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>To (local time)</Label>
|
||||
<DateField
|
||||
label="To time"
|
||||
defaultValue={to}
|
||||
onValueChange={setTo}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="small"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["meta"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppliedCustomDateRangeFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("from") === undefined && value("to") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fromDate = dateFromString(value("from"));
|
||||
const toDate = dateFromString(value("to"));
|
||||
|
||||
const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to";
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CustomDateRangeDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label={
|
||||
rangeType === "range"
|
||||
? "Created"
|
||||
: rangeType === "from"
|
||||
? "Created after"
|
||||
: "Created before"
|
||||
}
|
||||
value={
|
||||
<>
|
||||
{rangeType === "range" ? (
|
||||
<span>
|
||||
<DateTime date={fromDate!} includeTime includeSeconds /> –{" "}
|
||||
<DateTime date={toDate!} includeTime includeSeconds />
|
||||
</span>
|
||||
) : rangeType === "from" ? (
|
||||
<DateTime date={fromDate!} includeTime includeSeconds />
|
||||
) : (
|
||||
<DateTime date={toDate!} includeTime includeSeconds />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRemove={() => del(["period", "from", "to", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function appliedSummary(values: string[], maxValues = 3) {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values.length > maxValues) {
|
||||
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
function dateFromString(value: string | undefined | null): Date | undefined {
|
||||
if (!value) return;
|
||||
|
||||
//is it an int?
|
||||
const int = parseInt(value);
|
||||
if (!isNaN(int)) {
|
||||
return new Date(int);
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
exceptionEventEnhancer,
|
||||
isExceptionSpanEvent,
|
||||
@@ -5,6 +6,8 @@ import {
|
||||
type SpanEvent as OtelSpanEvent,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
@@ -75,11 +78,26 @@ export function SpanEventError({
|
||||
titleClassName="text-rose-500"
|
||||
/>
|
||||
{enhancedException.message && <Callout variant="error">{enhancedException.message}</Callout>}
|
||||
{enhancedException.link && (
|
||||
<Callout variant="docs" to={enhancedException.link.href}>
|
||||
{enhancedException.link.name}
|
||||
</Callout>
|
||||
)}
|
||||
{enhancedException.link &&
|
||||
(enhancedException.link.magic === "CONTACT_FORM" ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-400"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
{enhancedException.link.name}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Callout variant="docs" to={enhancedException.link.href}>
|
||||
{enhancedException.link.name}
|
||||
</Callout>
|
||||
))}
|
||||
{enhancedException.stacktrace && (
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function SpanInspector({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
|
||||
@@ -2,15 +2,14 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
RectangleStackIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
|
||||
import { TaskRunAttemptStatus } from "~/database-types";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { HourglassIcon, SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskRunAttemptStatus } from "~/database-types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allTaskRunAttemptStatuses = Object.values(
|
||||
@@ -65,7 +64,7 @@ export function TaskRunAttemptStatusIcon({
|
||||
case "EXECUTING":
|
||||
return <Spinner className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "PAUSED":
|
||||
return <SnowflakeIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
return <HourglassIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "CANCELED":
|
||||
@@ -91,7 +90,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus
|
||||
case "EXECUTING":
|
||||
return "text-pending";
|
||||
case "PAUSED":
|
||||
return "text-sky-300";
|
||||
return "text-charcoal-500";
|
||||
case "FAILED":
|
||||
return "text-error";
|
||||
case "CANCELED":
|
||||
@@ -117,7 +116,7 @@ export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null):
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "PAUSED":
|
||||
return "Frozen";
|
||||
return "Waiting";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "CANCELED":
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
RectangleStackIcon,
|
||||
StopIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -41,9 +40,9 @@ export const filterableTaskRunStatuses = [
|
||||
"WAITING_FOR_DEPLOY",
|
||||
"DELAYED",
|
||||
"PENDING",
|
||||
"WAITING_TO_RESUME",
|
||||
"EXECUTING",
|
||||
"RETRYING_AFTER_FAILURE",
|
||||
"WAITING_TO_RESUME",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
@@ -55,21 +54,21 @@ export const filterableTaskRunStatuses = [
|
||||
] as const satisfies Readonly<Array<TaskRunStatus>>;
|
||||
|
||||
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
|
||||
DELAYED: "Task has been delayed and is waiting to be executed",
|
||||
PENDING: "Task is waiting to be executed",
|
||||
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
|
||||
EXECUTING: "Task is currently being executed",
|
||||
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure",
|
||||
WAITING_TO_RESUME: "Task has been frozen and is waiting to be resumed",
|
||||
COMPLETED_SUCCESSFULLY: "Task has been successfully completed",
|
||||
CANCELED: "Task has been canceled",
|
||||
COMPLETED_WITH_ERRORS: "Task has failed with errors",
|
||||
INTERRUPTED: "Task has failed because it was interrupted",
|
||||
SYSTEM_FAILURE: "Task has failed due to a system failure",
|
||||
PAUSED: "Task has been paused by the user",
|
||||
CRASHED: "Task has crashed and won't be retried",
|
||||
EXPIRED: "Task has surpassed its ttl and won't be executed",
|
||||
TIMED_OUT: "Task has failed because it exceeded its maxDuration",
|
||||
DELAYED: "Task has been delayed and is waiting to be executed.",
|
||||
PENDING: "Task is waiting to be executed.",
|
||||
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing.",
|
||||
EXECUTING: "Task is currently being executed.",
|
||||
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure.",
|
||||
WAITING_TO_RESUME: `You have used a "wait" function. When the wait is complete, the task will resume execution.`,
|
||||
COMPLETED_SUCCESSFULLY: "Task has been successfully completed.",
|
||||
CANCELED: "Task has been canceled.",
|
||||
COMPLETED_WITH_ERRORS: "Task has failed with errors.",
|
||||
INTERRUPTED: "Task has failed because it was interrupted.",
|
||||
SYSTEM_FAILURE: "Task has failed due to a system failure.",
|
||||
PAUSED: "Task has been paused by the user.",
|
||||
CRASHED: "Task has crashed and won't be retried.",
|
||||
EXPIRED: "Task has surpassed its ttl and won't be executed.",
|
||||
TIMED_OUT: "Task has failed because it exceeded its maxDuration.",
|
||||
};
|
||||
|
||||
export const QUEUED_STATUSES = [
|
||||
@@ -126,7 +125,7 @@ export function TaskRunStatusIcon({
|
||||
case "EXECUTING":
|
||||
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "WAITING_TO_RESUME":
|
||||
return <SnowflakeIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
return <HourglassIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return <ArrowPathIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "PAUSED":
|
||||
@@ -165,7 +164,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return "text-pending";
|
||||
case "WAITING_TO_RESUME":
|
||||
return "text-sky-300";
|
||||
return "text-charcoal-500";
|
||||
case "PAUSED":
|
||||
return "text-amber-300";
|
||||
case "CANCELED":
|
||||
@@ -200,7 +199,7 @@ export function runStatusTitle(status: TaskRunStatus): string {
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "WAITING_TO_RESUME":
|
||||
return "Frozen";
|
||||
return "Waiting";
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return "Reattempting";
|
||||
case "PAUSED":
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
CpuChipIcon,
|
||||
NoSymbolIcon,
|
||||
RectangleStackIcon,
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
@@ -14,6 +15,7 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironments } from "~/hooks/useEnvironments";
|
||||
@@ -102,11 +104,11 @@ export function TaskRunsTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{allowSelection && (
|
||||
<TableHeaderCell className="pl-2 pr-0">
|
||||
<TableHeaderCell className="pl-3 pr-0">
|
||||
{runs.length > 0 && (
|
||||
<Checkbox
|
||||
checked={hasAll(runs.map((r) => r.id))}
|
||||
@@ -210,8 +212,9 @@ export function TaskRunsTable({
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="tertiary/small"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
@@ -233,8 +236,9 @@ export function TaskRunsTable({
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="tertiary/small"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
@@ -254,8 +258,9 @@ export function TaskRunsTable({
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tags")}
|
||||
variant="tertiary/small"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mt-3"
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
@@ -282,7 +287,7 @@ export function TaskRunsTable({
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
<TableCell className="pl-2 pr-0">
|
||||
<TableCell className="pl-3 pr-0">
|
||||
<Checkbox
|
||||
checked={has(run.id)}
|
||||
onChange={(element) => {
|
||||
@@ -367,7 +372,7 @@ export function TaskRunsTable({
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path}>
|
||||
{run.isTest ? <CheckIcon className="h-4 w-4 text-charcoal-400" /> : "–"}
|
||||
{run.isTest ? <CheckIcon className="size-4 text-charcoal-400" /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.createdAt ? <DateTime date={run.createdAt} /> : "–"}
|
||||
@@ -405,34 +410,114 @@ function RunActionsCell({ run, path }: { run: RunListItem; path: string }) {
|
||||
if (!run.isCancellable && !run.isReplayable) return <TableCell to={path}>{""}</TableCell>;
|
||||
|
||||
return (
|
||||
<TableCellMenu isSticky>
|
||||
{run.isCancellable && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon={StopCircleIcon}>
|
||||
Cancel run
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CancelRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View run"
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{run.isReplayable && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
|
||||
Replay run
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ReplayRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
failedRedirect={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</TableCellMenu>
|
||||
{run.isCancellable && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-error"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Cancel run
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CancelRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{run.isReplayable && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="h-6 w-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Replay run…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<ReplayRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
failedRedirect={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<div className="flex items-center">
|
||||
{run.isCancellable && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-bright transition hover:bg-charcoal-700"
|
||||
>
|
||||
<NoSymbolIcon className="size-3" />
|
||||
</DialogTrigger>
|
||||
<CancelRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
}
|
||||
content="Cancel run"
|
||||
side="left"
|
||||
disableHoverableContent
|
||||
/>
|
||||
)}
|
||||
{run.isCancellable && run.isReplayable && (
|
||||
<div className="mx-0.5 h-6 w-px bg-grid-dimmed" />
|
||||
)}
|
||||
{run.isReplayable && (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="h-6 w-6 rounded-sm p-1 text-text-bright transition hover:bg-charcoal-700"
|
||||
>
|
||||
<ArrowPathIcon className="size-3" />
|
||||
</DialogTrigger>
|
||||
<ReplayRunDialog
|
||||
runFriendlyId={run.friendlyId}
|
||||
failedRedirect={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
}
|
||||
content="Replay run…"
|
||||
side="left"
|
||||
disableHoverableContent
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -503,19 +588,29 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
|
||||
return (
|
||||
<TableBlankRow colSpan={14}>
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Paragraph className="w-auto" variant="small">
|
||||
No runs currently match your filters. Try refreshing or modifying your filters.
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<Paragraph className="w-auto" variant="base/bright">
|
||||
No runs match your filters. Try refreshing, modifying your filters or run a test.
|
||||
</Paragraph>
|
||||
<Button
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
variant="tertiary/small"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Paragraph>or</Paragraph>
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project)}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,9 @@ const EnvironmentSchema = z.object({
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
ELECTRIC_ORIGIN: z.string(),
|
||||
API_ORIGIN: z.string().optional(),
|
||||
STREAM_ORIGIN: z.string().optional(),
|
||||
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
|
||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||
SERVICE_NAME: z.string().default("trigger.dev webapp"),
|
||||
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
|
||||
@@ -42,9 +44,16 @@ const EnvironmentSchema = z.object({
|
||||
HIGHLIGHT_PROJECT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
|
||||
PLAIN_API_KEY: z.string().optional(),
|
||||
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
|
||||
WORKER_SCHEMA: z.string().default("graphile_worker"),
|
||||
@@ -103,6 +112,25 @@ const EnvironmentSchema = z.object({
|
||||
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
|
||||
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||||
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||||
API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||||
|
||||
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
|
||||
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
|
||||
|
||||
//Realtime rate limiting
|
||||
/**
|
||||
* @example "60s"
|
||||
* @example "1m"
|
||||
* @example "1h"
|
||||
* @example "1d"
|
||||
* @example "1000ms"
|
||||
* @example "1000s"
|
||||
*/
|
||||
REALTIME_RATE_LIMIT_WINDOW: z.string().default("1m"),
|
||||
REALTIME_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100),
|
||||
REALTIME_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||||
REALTIME_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||||
REALTIME_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||||
|
||||
//Ingesting event rate limit
|
||||
INGEST_EVENT_RATE_LIMIT_WINDOW: z.string().default("60s"),
|
||||
@@ -121,6 +149,10 @@ const EnvironmentSchema = z.object({
|
||||
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
|
||||
DEPLOY_REGISTRY_HOST: z.string().optional(),
|
||||
DEPLOY_REGISTRY_NAMESPACE: z.string().default("trigger"),
|
||||
DEPLOY_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60 * 1000 * 8), // 8 minutes
|
||||
OBJECT_STORE_BASE_URL: z.string().optional(),
|
||||
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
|
||||
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
@@ -170,8 +202,16 @@ const EnvironmentSchema = z.object({
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
/** These enable the alerts feature in v3 */
|
||||
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
ALERT_FROM_EMAIL: z.string().optional(),
|
||||
ALERT_REPLY_TO_EMAIL: z.string().optional(),
|
||||
ALERT_RESEND_API_KEY: z.string().optional(),
|
||||
ALERT_SMTP_HOST: z.string().optional(),
|
||||
ALERT_SMTP_PORT: z.coerce.number().optional(),
|
||||
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
|
||||
ALERT_SMTP_USER: z.string().optional(),
|
||||
ALERT_SMTP_PASSWORD: z.string().optional(),
|
||||
|
||||
|
||||
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
|
||||
|
||||
@@ -212,10 +252,14 @@ const EnvironmentSchema = z.object({
|
||||
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
|
||||
TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB
|
||||
TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
|
||||
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { requestUrl } from "./utils/requestUrl.server";
|
||||
export type TriggerFeatures = {
|
||||
isManagedCloud: boolean;
|
||||
v3Enabled: boolean;
|
||||
alertsEnabled: boolean;
|
||||
};
|
||||
|
||||
function isManagedCloud(host: string): boolean {
|
||||
@@ -20,7 +19,6 @@ function featuresForHost(host: string): TriggerFeatures {
|
||||
return {
|
||||
isManagedCloud: isManagedCloud(host),
|
||||
v3Enabled: env.V3_ENABLED === "true",
|
||||
alertsEnabled: env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server";
|
||||
export function useFeatures(): TriggerFeatures {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false, alertsEnabled: false };
|
||||
return routeMatch?.features ?? { isManagedCloud: false, v3Enabled: false };
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ import type {
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
|
||||
|
||||
import type {
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
TaskRun,
|
||||
TaskRunAttempt,
|
||||
TaskRunAttemptStatus as TaskRunAttemptStatusType,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
BatchTaskRunItemStatus as BatchTaskRunItemStatusType,
|
||||
} from "@trigger.dev/database";
|
||||
|
||||
import { assertNever } from "assert-never";
|
||||
@@ -50,6 +49,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: true,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
output: attempt.output ?? undefined,
|
||||
outputType: attempt.outputType,
|
||||
} satisfies TaskRunSuccessfulExecutionResult;
|
||||
@@ -60,9 +60,10 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "TASK_RUN_CANCELLED",
|
||||
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
@@ -92,9 +93,10 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "CONFIGURED_INCORRECTLY",
|
||||
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
@@ -102,6 +104,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: error.data,
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
export const MAX_TAGS_PER_RUN = 5;
|
||||
export const MAX_TAGS_PER_RUN = 10;
|
||||
|
||||
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
|
||||
if (tag.trim().length === 0) return;
|
||||
|
||||
@@ -15,7 +15,7 @@ import assertNever from "assert-never";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { generatePresignedUrl } from "~/v3/r2.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
|
||||
// Build 'select' object
|
||||
const commonRunSelect = {
|
||||
@@ -59,102 +59,93 @@ type CommonRelatedRun = Prisma.Result<
|
||||
"findFirstOrThrow"
|
||||
>;
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof ApiRetrieveRunPresenter.findRun>>>;
|
||||
|
||||
export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
public static async findRun(friendlyId: string, env: AuthenticatedEnvironment) {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: true,
|
||||
lockedToVersion: true,
|
||||
schedule: true,
|
||||
tags: true,
|
||||
batch: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
childRuns: {
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment,
|
||||
showSecretDetails: boolean
|
||||
taskRun: FoundRun,
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<RetrieveRunResponse | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
lockedToVersion: true,
|
||||
schedule: true,
|
||||
tags: true,
|
||||
batch: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
parentTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: commonRunSelect,
|
||||
},
|
||||
childRuns: {
|
||||
select: {
|
||||
...commonRunSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
logger.debug("Task run not found", { friendlyId, envId: env.id });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let $payload: any;
|
||||
let $payloadPresignedUrl: string | undefined;
|
||||
let $output: any;
|
||||
let $outputPresignedUrl: string | undefined;
|
||||
|
||||
if (showSecretDetails) {
|
||||
const payloadPacket = await conditionallyImportPacket({
|
||||
data: taskRun.payload,
|
||||
dataType: taskRun.payloadType,
|
||||
});
|
||||
const payloadPacket = await conditionallyImportPacket({
|
||||
data: taskRun.payload,
|
||||
dataType: taskRun.payloadType,
|
||||
});
|
||||
|
||||
if (
|
||||
payloadPacket.dataType === "application/store" &&
|
||||
typeof payloadPacket.data === "string"
|
||||
) {
|
||||
$payloadPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
payloadPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$payload = await parsePacket(payloadPacket);
|
||||
}
|
||||
if (
|
||||
payloadPacket.dataType === "application/store" &&
|
||||
typeof payloadPacket.data === "string"
|
||||
) {
|
||||
$payloadPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
payloadPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$payload = await parsePacket(payloadPacket);
|
||||
}
|
||||
|
||||
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
|
||||
const completedAttempt = taskRun.attempts.find(
|
||||
(a) => a.status === "COMPLETED" && typeof a.output !== null
|
||||
);
|
||||
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
|
||||
const completedAttempt = taskRun.attempts.find(
|
||||
(a) => a.status === "COMPLETED" && typeof a.output !== null
|
||||
);
|
||||
|
||||
if (completedAttempt && completedAttempt.output) {
|
||||
const outputPacket = await conditionallyImportPacket({
|
||||
data: completedAttempt.output,
|
||||
dataType: completedAttempt.outputType,
|
||||
});
|
||||
if (completedAttempt && completedAttempt.output) {
|
||||
const outputPacket = await conditionallyImportPacket({
|
||||
data: completedAttempt.output,
|
||||
dataType: completedAttempt.outputType,
|
||||
});
|
||||
|
||||
if (
|
||||
outputPacket.dataType === "application/store" &&
|
||||
typeof outputPacket.data === "string"
|
||||
) {
|
||||
$outputPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
outputPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$output = await parsePacket(outputPacket);
|
||||
}
|
||||
if (
|
||||
outputPacket.dataType === "application/store" &&
|
||||
typeof outputPacket.data === "string"
|
||||
) {
|
||||
$outputPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
outputPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$output = await parsePacket(outputPacket);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +156,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
payloadPresignedUrl: $payloadPresignedUrl,
|
||||
output: $output,
|
||||
outputPresignedUrl: $outputPresignedUrl,
|
||||
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
|
||||
schedule: taskRun.schedule
|
||||
? {
|
||||
id: taskRun.schedule.friendlyId,
|
||||
@@ -179,17 +171,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
attempts: !showSecretDetails
|
||||
? []
|
||||
: taskRun.attempts.map((a) => ({
|
||||
id: a.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
|
||||
createdAt: a.createdAt ?? undefined,
|
||||
updatedAt: a.updatedAt ?? undefined,
|
||||
startedAt: a.startedAt ?? undefined,
|
||||
completedAt: a.completedAt ?? undefined,
|
||||
error: ApiRetrieveRunPresenter.apiErrorFromError(a.error),
|
||||
})),
|
||||
// We're removing attempts from the API
|
||||
attemptCount: taskRun.attempts.length,
|
||||
attempts: [],
|
||||
relatedRuns: {
|
||||
root: taskRun.rootTaskRun
|
||||
? await createCommonRunStructure(taskRun.rootTaskRun)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ListRunResponse, ListRunResponseItem, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { ListRunResponse, ListRunResponseItem, parsePacket, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { Project, RuntimeEnvironment, TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
@@ -29,7 +29,7 @@ const CoercedDate = z.preprocess((arg) => {
|
||||
return arg;
|
||||
}, z.date().optional());
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
export const ApiRunListSearchParams = z.object({
|
||||
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
|
||||
"page[after]": z.string().optional(),
|
||||
"page[before]": z.string().optional(),
|
||||
@@ -119,47 +119,34 @@ const SearchParamsSchema = z.object({
|
||||
"filter[createdAt][from]": CoercedDate,
|
||||
"filter[createdAt][to]": CoercedDate,
|
||||
"filter[createdAt][period]": z.string().optional(),
|
||||
"filter[batch]": z.string().optional(),
|
||||
});
|
||||
|
||||
type SearchParamsSchema = z.infer<typeof SearchParamsSchema>;
|
||||
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
|
||||
|
||||
export class ApiRunListPresenter extends BasePresenter {
|
||||
public async call(
|
||||
project: Project,
|
||||
searchParams: URLSearchParams,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
environment?: RuntimeEnvironment
|
||||
): Promise<ListRunResponse> {
|
||||
return this.trace("call", async (span) => {
|
||||
const rawSearchParams = Object.fromEntries(searchParams.entries());
|
||||
const $searchParams = SearchParamsSchema.safeParse(rawSearchParams);
|
||||
|
||||
if (!$searchParams.success) {
|
||||
logger.error("Invalid search params", {
|
||||
searchParams: rawSearchParams,
|
||||
errors: $searchParams.error.errors,
|
||||
});
|
||||
|
||||
throw fromZodError($searchParams.error);
|
||||
}
|
||||
|
||||
logger.debug("Valid search params", { searchParams: $searchParams.data });
|
||||
|
||||
const options: RunListOptions = {
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// pagination
|
||||
if ($searchParams.data["page[size]"]) {
|
||||
options.pageSize = $searchParams.data["page[size]"];
|
||||
if (searchParams["page[size]"]) {
|
||||
options.pageSize = searchParams["page[size]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["page[after]"]) {
|
||||
options.cursor = $searchParams.data["page[after]"];
|
||||
if (searchParams["page[after]"]) {
|
||||
options.cursor = searchParams["page[after]"];
|
||||
options.direction = "forward";
|
||||
}
|
||||
|
||||
if ($searchParams.data["page[before]"]) {
|
||||
options.cursor = $searchParams.data["page[before]"];
|
||||
if (searchParams["page[before]"]) {
|
||||
options.cursor = searchParams["page[before]"];
|
||||
options.direction = "backward";
|
||||
}
|
||||
|
||||
@@ -167,12 +154,12 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
if (environment) {
|
||||
options.environments = [environment.id];
|
||||
} else {
|
||||
if ($searchParams.data["filter[env]"]) {
|
||||
if (searchParams["filter[env]"]) {
|
||||
const environments = await this._prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: {
|
||||
in: $searchParams.data["filter[env]"],
|
||||
in: searchParams["filter[env]"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -181,46 +168,50 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[status]"]) {
|
||||
options.statuses = $searchParams.data["filter[status]"].flatMap((status) =>
|
||||
if (searchParams["filter[status]"]) {
|
||||
options.statuses = searchParams["filter[status]"].flatMap((status) =>
|
||||
ApiRunListPresenter.apiStatusToRunStatuses(status)
|
||||
);
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[taskIdentifier]"]) {
|
||||
options.tasks = $searchParams.data["filter[taskIdentifier]"];
|
||||
if (searchParams["filter[taskIdentifier]"]) {
|
||||
options.tasks = searchParams["filter[taskIdentifier]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[version]"]) {
|
||||
options.versions = $searchParams.data["filter[version]"];
|
||||
if (searchParams["filter[version]"]) {
|
||||
options.versions = searchParams["filter[version]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[tag]"]) {
|
||||
options.tags = $searchParams.data["filter[tag]"];
|
||||
if (searchParams["filter[tag]"]) {
|
||||
options.tags = searchParams["filter[tag]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[bulkAction]"]) {
|
||||
options.bulkId = $searchParams.data["filter[bulkAction]"];
|
||||
if (searchParams["filter[bulkAction]"]) {
|
||||
options.bulkId = searchParams["filter[bulkAction]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[schedule]"]) {
|
||||
options.scheduleId = $searchParams.data["filter[schedule]"];
|
||||
if (searchParams["filter[schedule]"]) {
|
||||
options.scheduleId = searchParams["filter[schedule]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][from]"]) {
|
||||
options.from = $searchParams.data["filter[createdAt][from]"].getTime();
|
||||
if (searchParams["filter[createdAt][from]"]) {
|
||||
options.from = searchParams["filter[createdAt][from]"].getTime();
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][to]"]) {
|
||||
options.to = $searchParams.data["filter[createdAt][to]"].getTime();
|
||||
if (searchParams["filter[createdAt][to]"]) {
|
||||
options.to = searchParams["filter[createdAt][to]"].getTime();
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][period]"]) {
|
||||
options.period = $searchParams.data["filter[createdAt][period]"];
|
||||
if (searchParams["filter[createdAt][period]"]) {
|
||||
options.period = searchParams["filter[createdAt][period]"];
|
||||
}
|
||||
|
||||
if (typeof $searchParams.data["filter[isTest]"] === "boolean") {
|
||||
options.isTest = $searchParams.data["filter[isTest]"];
|
||||
if (typeof searchParams["filter[isTest]"] === "boolean") {
|
||||
options.isTest = searchParams["filter[isTest]"];
|
||||
}
|
||||
|
||||
if (searchParams["filter[batch]"]) {
|
||||
options.batchId = searchParams["filter[batch]"];
|
||||
}
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
@@ -229,36 +220,51 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
|
||||
const results = await presenter.call(options);
|
||||
|
||||
const data: ListRunResponseItem[] = results.runs.map((run) => {
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
});
|
||||
logger.debug("RunListPresenter results", { results });
|
||||
|
||||
const data: ListRunResponseItem[] = await Promise.all(
|
||||
results.runs.map(async (run) => {
|
||||
const metadata = await parsePacket(
|
||||
{
|
||||
data: run.metadata ?? undefined,
|
||||
dataType: run.metadataType,
|
||||
},
|
||||
{
|
||||
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
metadata,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { type Direction } from "~/components/runs/RunStatuses";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type BatchListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
//filters
|
||||
friendlyId?: string;
|
||||
statuses?: BatchTaskRunStatus[];
|
||||
environments?: string[];
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export type BatchList = Awaited<ReturnType<BatchListPresenter["call"]>>;
|
||||
export type BatchListItem = BatchList["batches"][0];
|
||||
export type BatchListAppliedFilters = BatchList["filters"];
|
||||
|
||||
export class BatchListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectId,
|
||||
friendlyId,
|
||||
statuses,
|
||||
environments,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: BatchListOptions) {
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
|
||||
const hasFilters =
|
||||
hasStatusFilters ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
friendlyId !== undefined ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
let environmentIds = project.environments.map((e) => e.id);
|
||||
if (environments && environments.length > 0) {
|
||||
//if environments are passed in, we only include them if they're in the project
|
||||
environmentIds = environments.filter((e) => project.environments.some((pe) => pe.id === e));
|
||||
}
|
||||
|
||||
if (environmentIds.length === 0) {
|
||||
throw new Error("No matching environments found for the project");
|
||||
}
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the batches
|
||||
const batches = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
runtimeEnvironmentId: string;
|
||||
status: BatchTaskRunStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
runCount: BigInt;
|
||||
batchVersion: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
b.id,
|
||||
b."friendlyId",
|
||||
b."runtimeEnvironmentId",
|
||||
b.status,
|
||||
b."createdAt",
|
||||
b."updatedAt",
|
||||
b."runCount",
|
||||
b."batchVersion"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BatchTaskRun" b
|
||||
WHERE
|
||||
-- environments
|
||||
b."runtimeEnvironmentId" IN (${Prisma.join(environmentIds)})
|
||||
-- cursor
|
||||
${
|
||||
cursor
|
||||
? direction === "forward"
|
||||
? Prisma.sql`AND b.id < ${cursor}`
|
||||
: Prisma.sql`AND b.id > ${cursor}`
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty}
|
||||
${
|
||||
statuses && statuses.length > 0
|
||||
? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(
|
||||
statuses
|
||||
)}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty}
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
|
||||
const hasMore = batches.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? batches.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
batches.reverse();
|
||||
if (hasMore) {
|
||||
previous = batches[1]?.id;
|
||||
next = batches[pageSize]?.id;
|
||||
} else {
|
||||
next = batches[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const batchesToReturn =
|
||||
direction === "backward" && hasMore
|
||||
? batches.slice(1, pageSize + 1)
|
||||
: batches.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
batches: batchesToReturn.map((batch) => {
|
||||
const environment = project.environments.find(
|
||||
(env) => env.id === batch.runtimeEnvironmentId
|
||||
);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for Batch ${batch.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = batch.status === "COMPLETED";
|
||||
|
||||
return {
|
||||
id: batch.id,
|
||||
friendlyId: batch.friendlyId,
|
||||
createdAt: batch.createdAt.toISOString(),
|
||||
updatedAt: batch.updatedAt.toISOString(),
|
||||
hasFinished,
|
||||
finishedAt: hasFinished ? batch.updatedAt.toISOString() : undefined,
|
||||
status: batch.status,
|
||||
environment: displayableEnvironment(environment, userId),
|
||||
runCount: Number(batch.runCount),
|
||||
batchVersion: batch.batchVersion,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
filters: {
|
||||
friendlyId,
|
||||
statuses: statuses || [],
|
||||
environments: environments || [],
|
||||
from,
|
||||
to,
|
||||
},
|
||||
hasFilters,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
},
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
},
|
||||
triggeredBy: {
|
||||
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
cliVersion: deployment.worker?.cliVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
|
||||
@@ -22,6 +22,9 @@ export type RunListOptions = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
isTest?: boolean;
|
||||
rootOnly?: boolean;
|
||||
batchId?: string;
|
||||
runId?: string;
|
||||
//pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
@@ -47,6 +50,9 @@ export class RunListPresenter extends BasePresenter {
|
||||
period,
|
||||
bulkId,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -66,7 +72,10 @@ export class RunListPresenter extends BasePresenter {
|
||||
to !== undefined ||
|
||||
(scheduleId !== undefined && scheduleId !== "") ||
|
||||
(tags !== undefined && tags.length > 0) ||
|
||||
typeof isTest === "boolean";
|
||||
batchId !== undefined ||
|
||||
runId !== undefined ||
|
||||
typeof isTest === "boolean" ||
|
||||
rootOnly === true;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
@@ -141,6 +150,43 @@ export class RunListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
//batch id is a friendly id
|
||||
if (batchId) {
|
||||
const batch = await this._replica.batchTaskRun.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: batchId,
|
||||
},
|
||||
});
|
||||
|
||||
if (batch) {
|
||||
batchId = batch.id;
|
||||
}
|
||||
}
|
||||
|
||||
//scheduleId can be a friendlyId
|
||||
if (scheduleId && scheduleId.startsWith("sched_")) {
|
||||
const schedule = await this._replica.taskSchedule.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: scheduleId,
|
||||
},
|
||||
});
|
||||
|
||||
if (schedule) {
|
||||
scheduleId = schedule?.id;
|
||||
}
|
||||
}
|
||||
|
||||
//show all runs if we are filtering by batchId or runId
|
||||
if (batchId || runId || scheduleId || tasks?.length) {
|
||||
rootOnly = false;
|
||||
}
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
@@ -166,9 +212,12 @@ export class RunListPresenter extends BasePresenter {
|
||||
costInCents: number;
|
||||
baseCostInCents: number;
|
||||
usageDurationMs: BigInt;
|
||||
tags: string[];
|
||||
tags: null | string[];
|
||||
depth: number;
|
||||
rootTaskRunId: string | null;
|
||||
batchId: string | null;
|
||||
metadata: string | null;
|
||||
metadataType: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -194,15 +243,13 @@ export class RunListPresenter extends BasePresenter {
|
||||
tr."usageDurationMs" AS "usageDurationMs",
|
||||
tr."depth" AS "depth",
|
||||
tr."rootTaskRunId" AS "rootTaskRunId",
|
||||
array_remove(array_agg(tag.name), NULL) AS "tags"
|
||||
tr."runTags" AS "tags",
|
||||
tr."metadata" AS "metadata",
|
||||
tr."metadataType" AS "metadataType"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A"
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
|
||||
WHERE
|
||||
-- project
|
||||
tr."projectId" = ${project.id}
|
||||
@@ -215,6 +262,8 @@ WHERE
|
||||
: Prisma.empty
|
||||
}
|
||||
-- filters
|
||||
${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty}
|
||||
${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty}
|
||||
${
|
||||
restrictToRunIds
|
||||
? restrictToRunIds.length === 0
|
||||
@@ -248,26 +297,16 @@ WHERE
|
||||
from
|
||||
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
}
|
||||
${
|
||||
to ? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty
|
||||
}
|
||||
${
|
||||
tags && tags.length > 0
|
||||
? Prisma.sql`AND (
|
||||
tr.id IN (
|
||||
SELECT
|
||||
trtg."A"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg
|
||||
JOIN
|
||||
${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id
|
||||
WHERE
|
||||
tag.name IN (${Prisma.join(tags)})
|
||||
)
|
||||
)`
|
||||
: Prisma.empty
|
||||
}
|
||||
}
|
||||
${
|
||||
tags && tags.length > 0
|
||||
? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]`
|
||||
: Prisma.empty
|
||||
}
|
||||
${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty}
|
||||
GROUP BY
|
||||
tr.id, bw.version
|
||||
ORDER BY
|
||||
@@ -336,9 +375,11 @@ WHERE
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
usageDurationMs: Number(run.usageDurationMs),
|
||||
tags: run.tags.sort((a, b) => a.localeCompare(b)),
|
||||
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
|
||||
depth: run.depth,
|
||||
rootTaskRunId: run.rootTaskRunId,
|
||||
metadata: run.metadata,
|
||||
metadataType: run.metadataType,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { machinePresetFromName } from "~/v3/machinePresets.server";
|
||||
import { FINAL_ATTEMPT_STATUSES, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { FINAL_ATTEMPT_STATUSES, isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { getMaxDuration } from "~/v3/utils/maxDuration";
|
||||
|
||||
@@ -149,6 +149,11 @@ export class SpanPresenter extends BasePresenter {
|
||||
spanId: true,
|
||||
},
|
||||
},
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
spanId,
|
||||
@@ -210,7 +215,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
const span = await eventRepository.getSpan(spanId, run.traceId);
|
||||
|
||||
const metadata = run.metadata
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType)
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, {
|
||||
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const context = {
|
||||
@@ -294,6 +301,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
usageDurationMs: run.usageDurationMs,
|
||||
isFinished,
|
||||
isRunning: RUNNING_STATUSES.includes(run.status),
|
||||
isError: isFailedRunStatus(run.status),
|
||||
payload,
|
||||
payloadType: run.payloadType,
|
||||
output,
|
||||
@@ -311,6 +319,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
context: JSON.stringify(context, null, 2),
|
||||
metadata,
|
||||
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
|
||||
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export type Task = {
|
||||
environments: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
slug: string;
|
||||
userName?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -113,10 +113,10 @@ export class TestPresenter extends BasePresenter {
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
>`WITH workers AS (
|
||||
SELECT
|
||||
SELECT
|
||||
bw.*,
|
||||
ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn
|
||||
FROM
|
||||
FROM
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw
|
||||
WHERE "runtimeEnvironmentId" = ${envId}
|
||||
),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import { RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { BackgroundWorkerTask, RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
|
||||
type TestTaskOptions = {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
taskFriendlyId: string;
|
||||
environmentSlug: string;
|
||||
taskIdentifier: string;
|
||||
};
|
||||
|
||||
type Task = {
|
||||
@@ -37,6 +39,15 @@ export type TestTask =
|
||||
runs: ScheduledRun[];
|
||||
};
|
||||
|
||||
export type TestTaskResult =
|
||||
| {
|
||||
foundTask: true;
|
||||
task: TestTask;
|
||||
}
|
||||
| {
|
||||
foundTask: false;
|
||||
};
|
||||
|
||||
type RawRun = {
|
||||
id: string;
|
||||
number: BigInt;
|
||||
@@ -71,55 +82,79 @@ export class TestTaskPresenter {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, projectSlug, taskFriendlyId }: TestTaskOptions): Promise<TestTask> {
|
||||
const task = await this.#prismaClient.backgroundWorkerTask.findFirstOrThrow({
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
taskIdentifier,
|
||||
}: TestTaskOptions): Promise<TestTaskResult> {
|
||||
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
|
||||
where: {
|
||||
slug: environmentSlug,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
orgMember: environmentSlug === "dev" ? { userId } : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
filePath: true,
|
||||
exportName: true,
|
||||
slug: true,
|
||||
triggerSource: true,
|
||||
runtimeEnvironment: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
orgMember: {
|
||||
user: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
friendlyId: taskFriendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
let task: BackgroundWorkerTask | null = null;
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
const deployment = await findCurrentWorkerDeployment(environment.id);
|
||||
if (deployment) {
|
||||
task = deployment.worker?.tasks.find((t) => t.slug === taskIdentifier) ?? null;
|
||||
}
|
||||
} else {
|
||||
task = await this.#prismaClient.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
slug: taskIdentifier,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
foundTask: false,
|
||||
};
|
||||
}
|
||||
|
||||
const latestRuns = await this.#prismaClient.$queryRaw<RawRun[]>`
|
||||
WITH taskruns AS (
|
||||
SELECT
|
||||
tr.*
|
||||
FROM
|
||||
SELECT
|
||||
tr.*
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorkerTask" as bwt
|
||||
ON
|
||||
tr."taskIdentifier" = bwt.slug
|
||||
WHERE
|
||||
bwt."friendlyId" = ${taskFriendlyId} AND
|
||||
tr."runtimeEnvironmentId" = ${task.runtimeEnvironment.id}
|
||||
ORDER BY
|
||||
bwt."friendlyId" = ${task.friendlyId} AND
|
||||
tr."runtimeEnvironmentId" = ${environment.id}
|
||||
ORDER BY
|
||||
tr."createdAt" DESC
|
||||
LIMIT 5
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
taskr.id,
|
||||
taskr.number,
|
||||
taskr."friendlyId",
|
||||
@@ -131,7 +166,7 @@ export class TestTaskPresenter {
|
||||
taskr."seedMetadata",
|
||||
taskr."seedMetadataType",
|
||||
taskr."runtimeEnvironmentId"
|
||||
FROM
|
||||
FROM
|
||||
taskruns AS taskr
|
||||
WHERE
|
||||
taskr."payloadType" = 'application/json' OR taskr."payloadType" = 'application/super+json'
|
||||
@@ -143,58 +178,64 @@ export class TestTaskPresenter {
|
||||
taskIdentifier: task.slug,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
friendlyId: taskFriendlyId,
|
||||
friendlyId: task.friendlyId,
|
||||
environment: {
|
||||
id: task.runtimeEnvironment.id,
|
||||
type: task.runtimeEnvironment.type,
|
||||
userId: task.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(task.runtimeEnvironment.orgMember?.user),
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userId: environment.orgMember?.user.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
};
|
||||
|
||||
switch (task.triggerSource) {
|
||||
case "STANDARD":
|
||||
return {
|
||||
triggerSource: "STANDARD",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
foundTask: true,
|
||||
task: {
|
||||
triggerSource: "STANDARD",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
metadata: r.seedMetadata
|
||||
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
),
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await prettyPrintPacket(r.payload, r.payloadType),
|
||||
metadata: r.seedMetadata
|
||||
? await prettyPrintPacket(r.seedMetadata, r.seedMetadataType)
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
};
|
||||
case "SCHEDULED":
|
||||
const possibleTimezones = getTimezones();
|
||||
return {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
possibleTimezones,
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
foundTask: true,
|
||||
task: {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
possibleTimezones,
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+610
-210
@@ -1,26 +1,52 @@
|
||||
import { ChatBubbleLeftRightIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
BeakerIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
LightBulbIcon,
|
||||
UserPlusIcon,
|
||||
VideoCameraIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
|
||||
import {
|
||||
InitCommandV3,
|
||||
PackageManagerProvider,
|
||||
TriggerDevStepV3,
|
||||
TriggerLoginStepV3,
|
||||
} from "~/components/SetupCommands";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import {
|
||||
@@ -28,7 +54,7 @@ import {
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellChevron,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
@@ -38,17 +64,30 @@ import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import {
|
||||
TaskTriggerSourceIcon,
|
||||
taskTriggerSourceDescription,
|
||||
TaskTriggerSourceIcon,
|
||||
} from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
|
||||
import {
|
||||
getUsefulLinksPreference,
|
||||
setUsefulLinksPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3RunsPath, v3TasksStreamingPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
inviteTeamMemberPath,
|
||||
ProjectParamSchema,
|
||||
v3RunsPath,
|
||||
v3TasksStreamingPath,
|
||||
v3TestPath,
|
||||
v3TestTaskPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -62,12 +101,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
const usefulLinksPreference = await getUsefulLinksPreference(request);
|
||||
|
||||
return typeddefer({
|
||||
tasks,
|
||||
userHasTasks,
|
||||
activity,
|
||||
runningStats,
|
||||
durations,
|
||||
usefulLinksPreference,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -78,10 +120,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
|
||||
|
||||
const session = await setUsefulLinksPreference(showUsefulLinks, request);
|
||||
|
||||
return json(
|
||||
{ success: true },
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const { tasks, userHasTasks, activity, runningStats, durations } =
|
||||
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
|
||||
items: tasks,
|
||||
@@ -123,6 +181,16 @@ export default function Page() {
|
||||
// WARNING Don't put the revalidator in the useEffect deps array or bad things will happen
|
||||
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
|
||||
|
||||
// Create a submit handler to save the preference
|
||||
const submit = useSubmit();
|
||||
|
||||
const handleUsefulLinksToggle = (show: boolean) => {
|
||||
setShowUsefulLinks(show);
|
||||
submit({ showUsefulLinks: show.toString() }, { method: "post" });
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -144,145 +212,223 @@ export default function Page() {
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/tasks/overview")}
|
||||
>
|
||||
Task docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className="pb-4">
|
||||
<div className="h-8">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Running</TableHeaderCell>
|
||||
<TableHeaderCell>Queued</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
return (
|
||||
<TableRow key={task.slug} className="group">
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(task.triggerSource)}
|
||||
/>
|
||||
<span>{task.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="py-0" actionClassName="py-0">
|
||||
<TaskFunctionName
|
||||
functionName={task.exportName}
|
||||
variant="extra-extra-small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<Spinner color="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.running ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.queued ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0" actionClassName="py-0">
|
||||
<Suspense fallback={<TaskActivityBlankState />}>
|
||||
<TypedAwait resolve={activity}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return (
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="tasks-main" className="max-h-full">
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="flex items-center p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{!showUsefulLinks && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
TrailingIcon={LightBulbIcon}
|
||||
onClick={() => handleUsefulLinksToggle(true)}
|
||||
className="px-2.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Table containerClassName="max-h-full pb-[2.5rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Task ID</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Running</TableHeaderCell>
|
||||
<TableHeaderCell>Queued</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
|
||||
const devYouEnvironment = task.environments.find(
|
||||
(e) => e.type === "DEVELOPMENT" && !e.userName
|
||||
);
|
||||
const firstDeployedEnvironment = task.environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.at(0);
|
||||
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
|
||||
|
||||
const testPath = testEnvironment
|
||||
? v3TestTaskPath(
|
||||
organization,
|
||||
project,
|
||||
{ taskIdentifier: task.slug },
|
||||
testEnvironment.slug
|
||||
)
|
||||
: v3TestPath(organization, project);
|
||||
|
||||
return (
|
||||
<TableRow key={task.slug} className="group">
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={<TaskTriggerSourceIcon source={task.triggerSource} />}
|
||||
content={taskTriggerSourceDescription(task.triggerSource)}
|
||||
/>
|
||||
<span>{task.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="py-0" actionClassName="py-0">
|
||||
<TaskFunctionName
|
||||
functionName={task.exportName}
|
||||
variant="extra-extra-small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
{taskData !== undefined ? (
|
||||
<div className="h-6 w-[5.125rem] rounded-sm">
|
||||
<TaskActivityGraph activity={taskData} />
|
||||
</div>
|
||||
) : (
|
||||
<TaskActivityBlankState />
|
||||
)}
|
||||
<Spinner color="muted" />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={durations}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData
|
||||
? formatDurationMilliseconds(taskData * 1000, {
|
||||
style: "short",
|
||||
})
|
||||
: "–";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.running ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={runningStats}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData?.queued ?? "0";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0" actionClassName="py-0">
|
||||
<Suspense fallback={<TaskActivityBlankState />}>
|
||||
<TypedAwait resolve={activity}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return (
|
||||
<>
|
||||
{taskData !== undefined ? (
|
||||
<div className="h-6 w-[5.125rem] rounded-sm">
|
||||
<TaskActivityGraph activity={taskData} />
|
||||
</div>
|
||||
) : (
|
||||
<TaskActivityBlankState />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} className="p-0">
|
||||
<Suspense fallback={<></>}>
|
||||
<TypedAwait resolve={durations}>
|
||||
{(data) => {
|
||||
const taskData = data[task.slug];
|
||||
return taskData
|
||||
? formatDurationMilliseconds(taskData * 1000, {
|
||||
style: "short",
|
||||
})
|
||||
: "–";
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon="runs"
|
||||
to={path}
|
||||
title="View runs"
|
||||
leadingIconClassName="text-teal-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon="beaker"
|
||||
to={testPath}
|
||||
title="Test task"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
to={testPath}
|
||||
>
|
||||
Test
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{hasTasks && showUsefulLinks ? (
|
||||
<>
|
||||
<ResizableHandle id="tasks-handle" />
|
||||
<ResizablePanel
|
||||
id="tasks-inspector"
|
||||
min="200px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
className="w-full"
|
||||
>
|
||||
<HelpfulInfoHasTasks onClose={() => handleUsefulLinksToggle(false)} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
) : null}
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
@@ -290,38 +436,40 @@ export default function Page() {
|
||||
|
||||
function CreateTaskInstructions() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You'll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in your project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You’ll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We’ve added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -329,43 +477,47 @@ function UserHasNoTasks() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Callout
|
||||
variant="info"
|
||||
cta={
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
TrailingIcon={open ? ChevronUpIcon : ChevronDownIcon}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? "Close" : "Setup your dev environment"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{open ? (
|
||||
<div>
|
||||
<Header2 spacing>Get setup in 3 minutes</Header2>
|
||||
<div className="px-2 pt-2">
|
||||
<Callout
|
||||
variant="info"
|
||||
cta={
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
TrailingIcon={open ? ChevronUpIcon : ChevronDownIcon}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? "Close" : "Setup your dev environment"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{open ? (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<Header2 spacing>Get setup in 3 minutes</Header2>
|
||||
|
||||
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerLoginStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
) : (
|
||||
"Your DEV environment isn't setup yet."
|
||||
)}
|
||||
</Callout>
|
||||
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerLoginStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
) : (
|
||||
"Your DEV environment isn't setup yet."
|
||||
)}
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -457,7 +609,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
return (
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
|
||||
<Header3 className="border-b border-b-charcoal-650 pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.status}>
|
||||
@@ -473,3 +625,251 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="overflow-y-scroll p-3 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-grid-dimmed pb-2">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<LightBulbIcon className="size-4 min-w-4 text-sun-500" />
|
||||
Helpful next steps
|
||||
</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-[0.375rem]"
|
||||
/>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to={v3TestPath(organization, project)}
|
||||
description="Test your tasks"
|
||||
icon={<BeakerIcon className="size-5 text-lime-500" />}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
description="Invite team members"
|
||||
icon={<UserPlusIcon className="size-5 text-amber-500" />}
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setIsVideoDialogOpen(true)}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
|
||||
variants["withIcon"].container
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={variants["withIcon"].iconContainer}>
|
||||
<VideoCameraIcon className="size-5 text-rose-500" />
|
||||
</div>
|
||||
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
|
||||
Watch a 14 min walkthrough video
|
||||
</Paragraph>
|
||||
</div>
|
||||
<AnimatingArrow direction="right" theme="dimmed" />
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to="https://trigger.dev/discord"
|
||||
description="Join our Discord for help and support"
|
||||
icon={<DiscordIcon className="size-5" />}
|
||||
isExternal
|
||||
/>
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<BookOpenIcon className="size-5 text-blue-500" />
|
||||
From the docs
|
||||
</Header2>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/writing-tasks-introduction")}
|
||||
description="How to write a task"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/tasks/scheduled")}
|
||||
description="Scheduled tasks (cron)"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon to={docsPath("/triggering")} description="How to trigger a task" isExternal />
|
||||
<LinkWithIcon to={docsPath("/cli-dev")} description="Running the CLI" isExternal />
|
||||
<LinkWithIcon
|
||||
to={docsPath("/how-it-works")}
|
||||
description="How Trigger.dev works"
|
||||
isExternal
|
||||
/>
|
||||
<div className="mb-2 flex items-center gap-2 border-b border-grid-dimmed pb-2 pt-6">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<TaskIcon className="size-4 text-blue-500" />
|
||||
Example tasks
|
||||
</Header2>
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/dall-e3-generate-image")}
|
||||
description="DALL·E 3 image generation"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/deepgram-transcribe-audio")}
|
||||
description="Deepgram audio transcription"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/fal-ai-image-to-cartoon")}
|
||||
description="Fal.ai image to cartoon"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/fal-ai-realtime")}
|
||||
description="Fal.ai with Realtime"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/ffmpeg-video-processing")}
|
||||
description="FFmpeg video processing"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/firecrawl-url-crawl")}
|
||||
description="Firecrawl URL crawl"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/libreoffice-pdf-conversion")}
|
||||
description="LibreOffice PDF conversion"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/open-ai-with-retrying")}
|
||||
description="OpenAI with retrying"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/pdf-to-image")}
|
||||
description="PDF to image"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon to={docsPath("/examples/puppeteer")} description="Puppeteer" isExternal />
|
||||
<LinkWithIcon to={docsPath("/examples/react-pdf")} description="React to PDF" isExternal />
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/resend-email-sequence")}
|
||||
description="Resend email sequence"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/scrape-hacker-news")}
|
||||
description="Scrape Hacker News"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/sentry-error-tracking")}
|
||||
description="Sentry error tracking"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/sharp-image-processing")}
|
||||
description="Sharp image processing"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/supabase-database-operations")}
|
||||
description="Supabase database operations"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/supabase-storage-upload")}
|
||||
description="Supabase Storage upload"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/vercel-ai-sdk")}
|
||||
description="Vercel AI SDK"
|
||||
isExternal
|
||||
/>
|
||||
<LinkWithIcon
|
||||
to={docsPath("/examples/vercel-sync-env-vars")}
|
||||
description="Vercel sync environment variables"
|
||||
isExternal
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={isVideoDialogOpen} onOpenChange={setIsVideoDialogOpen}>
|
||||
<DialogContent className="sm:max-w-screen-lg">
|
||||
<DialogHeader className="mb-4 pt-1">
|
||||
<DialogTitle>Trigger.dev walkthrough</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="aspect-video">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/YH_4c0K7fGM?si=BcX6MAt_V139sRw9"
|
||||
title="Trigger.dev walkthrough"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const variants = {
|
||||
withIcon: {
|
||||
container: "",
|
||||
iconContainer:
|
||||
"grid size-9 min-w-9 place-items-center rounded border border-transparent bg-charcoal-750 shadow transition group-hover:border-charcoal-650",
|
||||
},
|
||||
minimal: {
|
||||
container: "pl-3 py-2",
|
||||
iconContainer: "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type LinkWithIconProps = {
|
||||
to: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
isExternal?: boolean;
|
||||
variant?: keyof typeof variants;
|
||||
};
|
||||
|
||||
function LinkWithIcon({
|
||||
to,
|
||||
description,
|
||||
icon,
|
||||
isExternal,
|
||||
variant = "minimal",
|
||||
}: LinkWithIconProps) {
|
||||
const variation = variants[variant];
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={isExternal ? "noreferrer" : undefined}
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between gap-2 rounded-md p-1 pr-3 transition hover:bg-charcoal-750",
|
||||
variation.container
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{variant === "withIcon" && icon && <div className={variation.iconContainer}>{icon}</div>}
|
||||
<Paragraph variant="base" className="transition-colors group-hover:text-text-bright">
|
||||
{description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<AnimatingArrow direction={isExternal ? "topRight" : "right"} theme="dimmed" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-33
@@ -23,6 +23,7 @@ import { Label } from "~/components/primitives/Label";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -150,9 +151,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const option = url.searchParams.get("option");
|
||||
|
||||
const emailAlertsEnabled =
|
||||
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
|
||||
|
||||
return typedjson({
|
||||
...results,
|
||||
option: option === "slack" ? ("SLACK" as const) : undefined,
|
||||
emailAlertsEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +205,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option } = useTypedLoaderData<typeof loader>();
|
||||
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
@@ -271,16 +276,23 @@ export default function Page() {
|
||||
</InputGroup>
|
||||
|
||||
{currentAlertChannel === "EMAIL" ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
emailAlertsEnabled ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Email integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)
|
||||
) : currentAlertChannel === "SLACK" ? (
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
@@ -416,28 +428,21 @@ export default function Page() {
|
||||
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
<div className="border-t border-grid-bright pt-3">
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create"
|
||||
>
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button variant="primary/medium" disabled={isLoading} name="action" value="create">
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
+78
-46
@@ -37,6 +37,7 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -163,17 +164,17 @@ export default function Page() {
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/troubleshooting-alerts")}
|
||||
variant="minimal/small"
|
||||
variant="docs/small"
|
||||
>
|
||||
Alerts docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("flex h-full flex-col gap-3")}>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
<div className="flex items-end justify-between">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/small"
|
||||
@@ -182,8 +183,8 @@ export default function Page() {
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -225,20 +226,27 @@ export default function Page() {
|
||||
disabledIcon={BellSlashIcon}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</TableCellMenu>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</>
|
||||
}
|
||||
className={
|
||||
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Header2 spacing className="text-text-bright">
|
||||
You haven't created any project alerts yet
|
||||
@@ -261,40 +269,55 @@ export default function Page() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-stretch gap-3">
|
||||
{requiresUpgrade ? (
|
||||
<InfoPanel
|
||||
variant="upgrade"
|
||||
icon={LockOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
title="Unlock more alerts"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available alerts. Upgrade your plan to
|
||||
enable more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
) : (
|
||||
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your alerts.
|
||||
</Header3>
|
||||
<div className="flex h-fit items-stretch gap-3">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
requiresUpgrade ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available alerts. Upgrade your plan to
|
||||
enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your alerts.
|
||||
</Header3>
|
||||
)}
|
||||
|
||||
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
|
||||
<div
|
||||
className="h-full bg-grid-bright"
|
||||
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
@@ -328,6 +351,8 @@ function DeleteAlertChannelButton(props: { id: string }) {
|
||||
<Button
|
||||
name="action"
|
||||
value="delete"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
@@ -367,6 +392,8 @@ function DisableAlertChannelButton(props: { id: string }) {
|
||||
name="action"
|
||||
value="disable"
|
||||
type="submit"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BellSlashIcon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
@@ -405,6 +432,8 @@ function EnableAlertChannelButton(props: { id: string }) {
|
||||
name="action"
|
||||
value="enable"
|
||||
type="submit"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BellAlertIcon}
|
||||
leadingIconClassName="text-success"
|
||||
@@ -430,6 +459,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Email"}
|
||||
description={alertChannel.properties.email}
|
||||
boxClassName="group-hover/table-row:bg-charcoal-800"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -461,6 +491,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
|
||||
className="mt-1 w-80"
|
||||
/>
|
||||
}
|
||||
boxClassName="group-hover/table-row:bg-charcoal-800"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -476,6 +507,7 @@ function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListP
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Slack"}
|
||||
description={`#${alertChannel.properties.channelName}`}
|
||||
boxClassName="group-hover/table-row:bg-charcoal-800"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+30
-23
@@ -1,4 +1,5 @@
|
||||
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
@@ -72,7 +73,7 @@ export default function Page() {
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"minimal/small"}
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/v3/apikeys")}
|
||||
>
|
||||
@@ -80,9 +81,9 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className="mt-1 flex flex-col gap-4">
|
||||
<Table>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="flex flex-col">
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Environment</TableHeaderCell>
|
||||
@@ -104,7 +105,7 @@ export default function Page() {
|
||||
className="w-full max-w-none"
|
||||
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
|
||||
value={environment.apiKey}
|
||||
variant={"tertiary/small"}
|
||||
variant={"secondary/small"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -112,19 +113,22 @@ export default function Page() {
|
||||
</TableCell>
|
||||
<TableCell>{environment.latestVersion ?? "–"}</TableCell>
|
||||
<TableCell>{environment.environmentVariableCount}</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
<RegenerateApiKeyModal
|
||||
id={environment.id}
|
||||
title={environmentTitle(environment)}
|
||||
/>
|
||||
</TableCellMenu>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<RegenerateApiKeyModal
|
||||
id={environment.id}
|
||||
title={environmentTitle(environment)}
|
||||
/>
|
||||
}
|
||||
></TableCellMenu>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-sm">
|
||||
<div className="flex flex-wrap justify-between">
|
||||
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
|
||||
<Paragraph variant="small">
|
||||
Secret keys should be used on your server. They give full API access and allow you
|
||||
to <TextLink to={docsPath("v3/triggering")}>trigger tasks</TextLink> from your
|
||||
@@ -133,16 +137,19 @@ export default function Page() {
|
||||
</InfoPanel>
|
||||
|
||||
{!hasStaging && (
|
||||
<InfoPanel
|
||||
icon={LockOpenIcon}
|
||||
variant="upgrade"
|
||||
title="Unlock a Staging environment"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
iconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade your plan to add a Staging environment.
|
||||
</InfoPanel>
|
||||
<div className="flex items-center gap-2 pl-3 pr-2">
|
||||
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Upgrade to add a Staging environment
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
|
||||
import {
|
||||
allBatchStatuses,
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
BatchList,
|
||||
BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
id: url.searchParams.get("id") ?? undefined,
|
||||
};
|
||||
const filters = BatchListFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const presenter = new BatchListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
friendlyId: filters.id,
|
||||
});
|
||||
|
||||
return typedjson(list);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Batches" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/triggering")}
|
||||
>
|
||||
Batches docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters possibleEnvironments={project.environments} hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{allBatchStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<BatchStatusCombo status={status} />
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="!text-wrap text-text-dimmed">
|
||||
{descriptionForBatchStatus(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Runs</TableHeaderCell>
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Finished</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to batch</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.length === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
{!isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</TableBlankRow>
|
||||
) : batches.length === 0 ? (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches match these filters</Paragraph>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, batch);
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path}>{batch.friendlyId}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel
|
||||
environment={batch.environment}
|
||||
userName={batch.environment.userName}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
content="Upgrade to the latest SDK for batch statuses to appear."
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span className="flex items-center gap-1">
|
||||
<ExclamationCircleIcon className="size-4 text-slate-500" />
|
||||
<span>Legacy batch</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
content={descriptionForBatchStatus(batch.status)}
|
||||
disableHoverableContent
|
||||
button={<BatchStatusCombo status={batch.status} />}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>{batch.runCount}</TableCell>
|
||||
<TableCell to={path} className="w-[1%]" actionClassName="pr-0 tabular-nums">
|
||||
{batch.finishedAt ? (
|
||||
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : (
|
||||
<LiveTimer startTime={new Date(batch.createdAt)} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<DateTime date={batch.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+58
-46
@@ -1,4 +1,8 @@
|
||||
import { ArrowUpCircleIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowUpCircleIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Await } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
@@ -27,6 +31,8 @@ import {
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { LockOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -62,7 +68,7 @@ export default function Page() {
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
<LinkButton
|
||||
variant={"minimal/small"}
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/queue-concurrency")}
|
||||
>
|
||||
@@ -70,50 +76,56 @@ export default function Page() {
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Header2>Environments</Header2>
|
||||
{plan ? (
|
||||
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button LeadingIcon={ArrowUpCircleIcon} variant="tertiary/small">
|
||||
Request more concurrency
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
) : (
|
||||
<LinkButton
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
to={v3BillingPath(organization)}
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Upgrade for more concurrency
|
||||
</LinkButton>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
|
||||
{(environments) => <EnvironmentsTable environments={environments} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="flex flex-col">
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Environment</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Queued</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Running</TableHeaderCell>
|
||||
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
|
||||
{(environments) => <EnvironmentsTable environments={environments} />}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{plan ? (
|
||||
plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? (
|
||||
<div className="flex w-full items-center justify-end gap-2 pl-3 pr-2 pt-3">
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Need more concurrency?
|
||||
</Paragraph>
|
||||
<Feedback
|
||||
button={
|
||||
<Button LeadingIcon={ChatBubbleLeftEllipsisIcon} variant="tertiary/small">
|
||||
Request more
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-end gap-2 pl-3 pr-2 pt-3">
|
||||
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Upgrade for more concurrency
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
|
||||
+4
@@ -151,6 +151,10 @@ export default function Page() {
|
||||
<Property.Label>SDK Version</Property.Label>
|
||||
<Property.Value>{deployment.sdkVersion ? deployment.sdkVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CLI Version</Property.Label>
|
||||
<Property.Value>{deployment.cliVersion ? deployment.cliVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Started at</Property.Label>
|
||||
<Property.Value>
|
||||
|
||||
+200
-134
@@ -16,7 +16,7 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatuses,
|
||||
deploymentStatusDescription,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
|
||||
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
DeploymentListPresenter,
|
||||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
@@ -98,111 +103,148 @@ export default function Page() {
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Deployments" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/cli-deploy")}
|
||||
>
|
||||
Deployments docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanel id="deployments-main" min="100px" className="max-h-full overflow-y-auto">
|
||||
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
|
||||
{hasDeployments ? (
|
||||
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Tasks</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deployments.length > 0 ? (
|
||||
deployments.map((deployment) => {
|
||||
const usernameForEnv =
|
||||
user.id !== deployment.environment.userId
|
||||
? deployment.environment.userName
|
||||
: undefined;
|
||||
const path = v3DeploymentPath(
|
||||
organization,
|
||||
project,
|
||||
deployment,
|
||||
currentPage
|
||||
);
|
||||
return (
|
||||
<TableRow key={deployment.id} className="group">
|
||||
<TableCell to={path}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small">
|
||||
{deployment.shortCode}
|
||||
</Paragraph>
|
||||
{deployment.label && (
|
||||
<Badge variant="outline-rounded">{deployment.label}</Badge>
|
||||
)}
|
||||
<div className="grid max-h-full grid-rows-[1fr_auto]">
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{deploymentStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<DeploymentStatus status={status} isBuilt={false} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel
|
||||
environment={deployment.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>{deployment.version}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<DeploymentStatus
|
||||
status={deployment.status}
|
||||
isBuilt={deployment.isBuilt}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{deployment.tasksCount !== null ? deployment.tasksCount : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{deployment.deployedAt ? (
|
||||
<DateTime date={deployment.deployedAt} />
|
||||
) : (
|
||||
"–"
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="!text-wrap text-text-dimmed"
|
||||
>
|
||||
{deploymentStatusDescription(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Tasks</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deployments.length > 0 ? (
|
||||
deployments.map((deployment) => {
|
||||
const usernameForEnv =
|
||||
user.id !== deployment.environment.userId
|
||||
? deployment.environment.userName
|
||||
: undefined;
|
||||
const path = v3DeploymentPath(
|
||||
organization,
|
||||
project,
|
||||
deployment,
|
||||
currentPage
|
||||
);
|
||||
const isSelected = deploymentParam === deployment.shortCode;
|
||||
return (
|
||||
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
|
||||
{deployment.label && (
|
||||
<Badge variant="outline-rounded">{deployment.label}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{deployment.deployedBy ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
avatarUrl={deployment.deployedBy.avatarUrl}
|
||||
name={
|
||||
deployment.deployedBy.name ??
|
||||
deployment.deployedBy.displayName
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Paragraph variant="extra-small">
|
||||
{deployment.deployedBy.name ??
|
||||
deployment.deployedBy.displayName}
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<DeploymentActionsCell deployment={deployment} path={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={6}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No deploys match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-end">
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<EnvironmentLabel
|
||||
environment={deployment.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.version}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<DeploymentStatus
|
||||
status={deployment.status}
|
||||
isBuilt={deployment.isBuilt}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.tasksCount !== null ? deployment.tasksCount : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.deployedAt ? (
|
||||
<DateTime date={deployment.deployedAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.deployedBy ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
avatarUrl={deployment.deployedBy.avatarUrl}
|
||||
name={
|
||||
deployment.deployedBy.name ??
|
||||
deployment.deployedBy.displayName
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Paragraph variant="extra-small">
|
||||
{deployment.deployedBy.name ??
|
||||
deployment.deployedBy.displayName}
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<DeploymentActionsCell
|
||||
deployment={deployment}
|
||||
path={path}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph className="flex items-center justify-center">
|
||||
No deploys match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<div className="-mt-px flex justify-end border-t border-grid-dimmed py-2 pr-2">
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CreateDeploymentInstructions />
|
||||
@@ -212,7 +254,7 @@ export default function Page() {
|
||||
{deploymentParam && (
|
||||
<>
|
||||
<ResizableHandle id="deployments-handle" />
|
||||
<ResizablePanel id="deployments-inspector" min="225px" max="500px">
|
||||
<ResizablePanel id="deployments-inspector" min="400px" max="700px">
|
||||
<Outlet />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
@@ -231,7 +273,7 @@ function CreateDeploymentInstructions() {
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-blue-400"
|
||||
iconClassName="text-blue-500"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
@@ -247,7 +289,7 @@ function CreateDeploymentInstructions() {
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="tertiary/small"
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
@@ -255,7 +297,7 @@ function CreateDeploymentInstructions() {
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="tertiary/small"
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
@@ -270,9 +312,11 @@ function CreateDeploymentInstructions() {
|
||||
function DeploymentActionsCell({
|
||||
deployment,
|
||||
path,
|
||||
isSelected,
|
||||
}: {
|
||||
deployment: DeploymentListItem;
|
||||
path: string;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const project = useProject();
|
||||
@@ -281,39 +325,61 @@ function DeploymentActionsCell({
|
||||
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
|
||||
|
||||
if (!canRollback && !canRetryIndexing) {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu isSticky>
|
||||
{canRollback && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon={ArrowUturnLeftIcon}>
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RollbackDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canRetryIndexing && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
|
||||
Retry indexing
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RetryDeploymentIndexingDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</TableCellMenu>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
isSelected={isSelected}
|
||||
popoverContent={
|
||||
<>
|
||||
{canRollback && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowUturnLeftIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Rollback…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RollbackDeploymentDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
{canRetryIndexing && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Retry indexing…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<RetryDeploymentIndexingDialog
|
||||
projectId={project.id}
|
||||
deploymentShortCode={deployment.shortCode}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -311,7 +311,6 @@ export default function Page() {
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
|
||||
+62
-49
@@ -8,6 +8,7 @@ import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
@@ -28,6 +29,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
@@ -184,33 +186,33 @@ export default function Page() {
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/deploy-environment-variables")}
|
||||
variant="minimal/small"
|
||||
variant="docs/small"
|
||||
>
|
||||
Environment variables docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("flex h-full flex-col gap-3")}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{environmentVariables.length > 0 && (
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("flex h-full flex-col")}>
|
||||
{environmentVariables.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 px-2 py-2">
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal values"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
)}
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Table>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
<Table containerClassName={cn(environmentVariables.length === 0 && "border-t-0")}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Key</TableHeaderCell>
|
||||
@@ -239,26 +241,39 @@ export default function Page() {
|
||||
className="-ml-2"
|
||||
secure={!revealAll}
|
||||
value={value}
|
||||
variant={"tertiary/small"}
|
||||
variant={"secondary/small"}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCellMenu isSticky>
|
||||
<EditEnvironmentVariablePanel
|
||||
environments={environments}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
/>
|
||||
<DeleteEnvironmentVariableButton variable={variable} />
|
||||
</TableCellMenu>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<EditEnvironmentVariablePanel
|
||||
environments={environments}
|
||||
variable={variable}
|
||||
revealAll={revealAll}
|
||||
/>
|
||||
<DeleteEnvironmentVariableButton variable={variable} />
|
||||
</>
|
||||
}
|
||||
></TableCellMenu>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={environments.length + 2}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph>No environment variables have been set</Paragraph>
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -266,22 +281,25 @@ export default function Page() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem]">
|
||||
<div className="z-10 -mt-px flex w-full flex-wrap justify-between border-t border-grid-dimmed">
|
||||
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
|
||||
Dev environment variables specified here will be overridden by ones in your .env file
|
||||
when running locally.
|
||||
</InfoPanel>
|
||||
{!hasStaging && (
|
||||
<InfoPanel
|
||||
icon={LockOpenIcon}
|
||||
variant="upgrade"
|
||||
title="Unlock a Staging environment"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
iconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade your plan to add a Staging environment.
|
||||
</InfoPanel>
|
||||
<div className="flex items-center gap-2 pl-3 pr-2">
|
||||
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Upgrade to add a Staging environment
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,14 +346,7 @@ function EditEnvironmentVariablePanel({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
leadingIconClassName="text-charcoal-500"
|
||||
className="text-xs"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<Button variant="small-menu-item" LeadingIcon={PencilSquareIcon} fullWidth textAlignLeft>
|
||||
Edit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -457,9 +468,11 @@ function DeleteEnvironmentVariableButton({
|
||||
value="delete"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
className="text-xs"
|
||||
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
|
||||
className="transition-colors group-hover/button:bg-error"
|
||||
>
|
||||
{isLoading ? "Deleting" : "Delete"}
|
||||
</Button>
|
||||
|
||||
+5
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrowUturnLeftIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -70,6 +71,7 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { lerp } from "~/utils/lerp";
|
||||
import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3RunParamsSchema,
|
||||
v3RunPath,
|
||||
@@ -193,6 +195,9 @@ export default function Page() {
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/runs")}>
|
||||
Run docs
|
||||
</LinkButton>
|
||||
<Dialog key={`replay-${run.friendlyId}`}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
|
||||
+109
-58
@@ -21,7 +21,7 @@ import {
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
SelectedItemsProvider,
|
||||
@@ -35,12 +35,22 @@ import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { RunListPresenter } from "~/presenters/v3/RunListPresenter.server";
|
||||
import {
|
||||
getRootOnlyFilterPreference,
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3ProjectPath, v3RunsPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
docsPath,
|
||||
ProjectParamSchema,
|
||||
v3ProjectPath,
|
||||
v3RunsPath,
|
||||
v3TestPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
@@ -48,6 +58,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
let rootOnlyValue = false;
|
||||
if (url.searchParams.has("rootOnly")) {
|
||||
rootOnlyValue = url.searchParams.get("rootOnly") === "true";
|
||||
} else {
|
||||
rootOnlyValue = await getRootOnlyFilterPreference(request);
|
||||
}
|
||||
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
@@ -57,6 +75,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
bulkId: url.searchParams.get("bulkId") ?? undefined,
|
||||
tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)),
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
rootOnly: rootOnlyValue,
|
||||
runId: url.searchParams.get("runId") ?? undefined,
|
||||
batchId: url.searchParams.get("batchId") ?? undefined,
|
||||
scheduleId: url.searchParams.get("scheduleId") ?? undefined,
|
||||
};
|
||||
const {
|
||||
tasks,
|
||||
@@ -70,6 +94,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
to,
|
||||
cursor,
|
||||
direction,
|
||||
rootOnly,
|
||||
runId,
|
||||
batchId,
|
||||
scheduleId,
|
||||
} = TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
@@ -91,26 +119,49 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
bulkId,
|
||||
from,
|
||||
to,
|
||||
batchId,
|
||||
runId,
|
||||
scheduleId,
|
||||
rootOnly,
|
||||
direction: direction,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: list,
|
||||
});
|
||||
const session = await setRootOnlyFilterPreference(rootOnlyValue, request);
|
||||
const cookieValue = await uiPreferencesStorage.commitSession(session);
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: list,
|
||||
rootOnlyDefault: rootOnlyValue,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": cookieValue,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data } = useTypedLoaderData<typeof loader>();
|
||||
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle title="Runs" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/runs-and-attempts")}
|
||||
>
|
||||
Runs docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<SelectedItemsProvider
|
||||
@@ -121,61 +172,61 @@ export default function Page() {
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden",
|
||||
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_3.5rem]"
|
||||
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-start justify-between gap-x-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
allowSelection
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
)}
|
||||
@@ -198,7 +249,7 @@ function BulkActionBar() {
|
||||
initial={{ translateY: "100%" }}
|
||||
animate={{ translateY: 0 }}
|
||||
exit={{ translateY: "100%" }}
|
||||
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright pl-4 pr-3"
|
||||
className="flex items-center justify-between gap-3 border-t border-grid-bright bg-background-bright py-3 pl-4 pr-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-sm text-text-bright">
|
||||
<ListChecks className="mr-1 size-7 text-indigo-400" />
|
||||
|
||||
+12
-13
@@ -216,9 +216,10 @@ export default function Page() {
|
||||
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={`${v3SchedulesPath(organization, project)}${location.search}`}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
@@ -252,7 +253,7 @@ export default function Page() {
|
||||
<Property.Label>Timezone</Property.Label>
|
||||
<Property.Value>{schedule.timezone}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Environments</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentLabels size="small" environments={schedule.environments} />
|
||||
@@ -272,7 +273,7 @@ export default function Page() {
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Item className="gap-1.5">
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
@@ -354,11 +355,11 @@ export default function Page() {
|
||||
</div>
|
||||
{isImperative && (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
@@ -371,26 +372,24 @@ export default function Page() {
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="minimal/medium"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error"
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Delete schedule</DialogHeader>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="mt-3">
|
||||
Are you sure you want to delete this schedule? This can't be reversed.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Form method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
|
||||
+91
-54
@@ -63,6 +63,8 @@ import {
|
||||
v3SchedulePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -94,7 +96,6 @@ export default function Page() {
|
||||
possibleTasks,
|
||||
possibleEnvironments,
|
||||
hasFilters,
|
||||
filters,
|
||||
limits,
|
||||
currentPage,
|
||||
totalPages,
|
||||
@@ -132,11 +133,20 @@ export default function Page() {
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/tasks/scheduled")}
|
||||
>
|
||||
Schedules docs
|
||||
</LinkButton>
|
||||
|
||||
{limits.used >= limits.limit ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-background-dimmed"
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
disabled={possibleTasks.length === 0 || isShowingNewPane}
|
||||
@@ -177,16 +187,16 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="schedules-main" min={"100px"}>
|
||||
<div className="max-h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
{possibleTasks.length === 0 ? (
|
||||
<CreateScheduledTaskInstructions />
|
||||
) : schedules.length === 0 && !hasFilters ? (
|
||||
<AttachYourFirstScheduleInstructions />
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-x-2 p-2">
|
||||
<ScheduleFilters
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
possibleTasks={possibleTasks}
|
||||
@@ -200,51 +210,73 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
|
||||
<div className="mt-3 flex w-full items-start justify-between">
|
||||
{requiresUpgrade ? (
|
||||
<InfoPanel
|
||||
variant="upgrade"
|
||||
icon={LockOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
title="Unlock more schedules"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available schedules. Upgrade your
|
||||
plan to enable more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
) : (
|
||||
<div className="flex h-fit flex-col items-start gap-4 rounded-md border border-grid-bright bg-background-bright p-4">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="h-fit max-h-full overflow-x-auto">
|
||||
<SchedulesTable schedules={schedules} hasFilters={hasFilters} />
|
||||
<div className="flex justify-end py-3">
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
requiresUpgrade ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available schedules. Upgrade your
|
||||
plan to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your schedules.
|
||||
</Header3>
|
||||
)}
|
||||
|
||||
{canUpgrade ? (
|
||||
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full border border-grid-bright">
|
||||
<div
|
||||
className="h-full bg-grid-bright"
|
||||
style={{ width: `${(limits.used / limits.limit) * 100}%` }}
|
||||
{canUpgrade ? (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -270,13 +302,18 @@ function CreateScheduledTaskInstructions() {
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-sun-500"
|
||||
panelClassName="max-w-full"
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
buttonLabel="Scheduled task docs"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
<Paragraph spacing variant="small">
|
||||
You have no scheduled tasks in your project. Before you can schedule a task you need to
|
||||
create a <InlineCode>schedules.task</InlineCode>.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
View the docs
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
@@ -372,14 +409,14 @@ function SchedulesTable({
|
||||
deleted from the dashboard or using the SDK.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to="https://trigger.dev/docs/v3/tasks-scheduled"
|
||||
>
|
||||
View the docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
<LinkButton
|
||||
variant="docs/small"
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="mb-1"
|
||||
>
|
||||
View the docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
+53
-31
@@ -5,7 +5,7 @@ import { Form, useActionData, useSubmit } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { JSONEditor } from "~/components/code/JSONEditor";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -32,7 +32,11 @@ import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { TimezoneList } from "~/components/scheduled/timezones";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import {
|
||||
ScheduledRun,
|
||||
StandardRun,
|
||||
@@ -42,7 +46,7 @@ import {
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath, v3RunSpanPath, v3TaskParamsSchema } from "~/utils/pathBuilder";
|
||||
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { TestTaskService } from "~/v3/services/testTask.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { TestTaskData } from "~/v3/testTask";
|
||||
@@ -51,14 +55,30 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, taskParam } = v3TaskParamsSchema.parse(params);
|
||||
|
||||
const presenter = new TestTaskPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
taskFriendlyId: taskParam,
|
||||
});
|
||||
//need an environment
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const environment = searchParams.get("environment");
|
||||
if (!environment) {
|
||||
return redirect(v3TestPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
const presenter = new TestTaskPresenter();
|
||||
try {
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
taskIdentifier: taskParam,
|
||||
environmentSlug: environment,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
} catch (error) {
|
||||
return redirectWithErrorMessage(
|
||||
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
|
||||
request,
|
||||
`Couldn't load test page for ${taskParam}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
@@ -113,16 +133,20 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
export default function Page() {
|
||||
const result = useTypedLoaderData<typeof loader>();
|
||||
|
||||
switch (result.triggerSource) {
|
||||
if (!result.foundTask) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
switch (result.task.triggerSource) {
|
||||
case "STANDARD": {
|
||||
return <StandardTaskForm task={result.task} runs={result.runs} />;
|
||||
return <StandardTaskForm task={result.task.task} runs={result.task.runs} />;
|
||||
}
|
||||
case "SCHEDULED": {
|
||||
return (
|
||||
<ScheduledTaskForm
|
||||
task={result.task}
|
||||
runs={result.runs}
|
||||
possibleTimezones={result.possibleTimezones}
|
||||
task={result.task.task}
|
||||
runs={result.task.runs}
|
||||
possibleTimezones={result.task.possibleTimezones}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -180,7 +204,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
);
|
||||
e.preventDefault();
|
||||
},
|
||||
[currentPayloadJson, currentMetadataJson]
|
||||
[currentPayloadJson, currentMetadataJson, task]
|
||||
);
|
||||
|
||||
const [form, { environmentId, payload }] = useForm({
|
||||
@@ -194,7 +218,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="grid h-full max-h-full grid-rows-[1fr_2.5rem]"
|
||||
className="grid h-full max-h-full grid-rows-[1fr_auto]"
|
||||
method="post"
|
||||
{...form.props}
|
||||
onSubmit={(e) => submitForm(e)}
|
||||
@@ -202,7 +226,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
<input type="hidden" name="triggerSource" value={"STANDARD"} />
|
||||
<ResizablePanelGroup orientation="horizontal">
|
||||
<ResizablePanel id="test-task-main" min="100px" default="60%">
|
||||
<div className="h-full bg-charcoal-900">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-charcoal-900">
|
||||
<TabContainer className="px-3 pt-2">
|
||||
<TabButton
|
||||
isActive={!tab || tab === "payload"}
|
||||
@@ -224,7 +248,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
Metadata
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<JSONEditor
|
||||
defaultValue={defaultPayloadJson}
|
||||
readOnly={false}
|
||||
@@ -241,11 +265,9 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
}
|
||||
}}
|
||||
height="100%"
|
||||
min-height="100%"
|
||||
max-height="100%"
|
||||
autoFocus={!tab || tab === "payload"}
|
||||
placeholder="{ }"
|
||||
className={cn("h-full", tab === "metadata" && "hidden")}
|
||||
className={cn("h-full overflow-auto", tab === "metadata" && "hidden")}
|
||||
/>
|
||||
<JSONEditor
|
||||
defaultValue={defaultMetadataJson}
|
||||
@@ -263,11 +285,9 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
}
|
||||
}}
|
||||
height="100%"
|
||||
min-height="100%"
|
||||
max-height="100%"
|
||||
autoFocus={tab === "metadata"}
|
||||
placeholder=""
|
||||
className={cn("h-full", tab !== "metadata" && "hidden")}
|
||||
className={cn("h-full overflow-auto", tab !== "metadata" && "hidden")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,16 +307,16 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
|
||||
<div className="flex items-center justify-end gap-3 border-t border-grid-bright bg-background-dimmed p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Paragraph variant="small" className="whitespace-nowrap">
|
||||
This test will run in
|
||||
</Paragraph>
|
||||
<EnvironmentLabel environment={task.environment} />
|
||||
<EnvironmentLabel environment={task.environment} size="large" />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
variant="primary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
shortcut={{ key: "enter", modifiers: ["mod"], enabledOnInputElements: true }}
|
||||
>
|
||||
@@ -358,7 +378,7 @@ function ScheduledTaskForm({
|
||||
});
|
||||
|
||||
return (
|
||||
<Form className="grid h-full max-h-full grid-rows-[1fr_2.5rem]" method="post" {...form.props}>
|
||||
<Form className="grid h-full max-h-full grid-rows-[1fr_auto]" method="post" {...form.props}>
|
||||
<input
|
||||
type="hidden"
|
||||
{...conform.input(triggerSource, { type: "hidden" })}
|
||||
@@ -392,6 +412,7 @@ function ScheduledTaskForm({
|
||||
granularity="second"
|
||||
showNowButton
|
||||
variant="medium"
|
||||
utc
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the CRON, it will come through to your run in the
|
||||
@@ -416,6 +437,7 @@ function ScheduledTaskForm({
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
utc
|
||||
/>
|
||||
<Hint>
|
||||
This is the timestamp of the previous run. You can use this in your code to find
|
||||
@@ -484,7 +506,7 @@ function ScheduledTaskForm({
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed px-2">
|
||||
<div className="flex items-center justify-end gap-2 border-t border-grid-bright bg-background-dimmed p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Paragraph variant="small" className="whitespace-nowrap">
|
||||
This test will run in
|
||||
@@ -519,7 +541,7 @@ function RecentPayloads({
|
||||
onSelected: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<div className="flex h-10 items-center border-b border-grid-dimmed">
|
||||
<Header2>Recent payloads</Header2>
|
||||
</div>
|
||||
|
||||
+67
-23
@@ -1,3 +1,4 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -8,9 +9,11 @@ import {
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
import {
|
||||
@@ -42,7 +45,7 @@ import {
|
||||
} from "~/presenters/v3/TestPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, ProjectParamSchema, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const TestSearchParams = z.object({
|
||||
environment: z.string().optional(),
|
||||
@@ -75,18 +78,23 @@ export default function Page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const location = useLocation();
|
||||
const locationSearchParams = new URLSearchParams(location.search);
|
||||
const navigationSearchParams = new URLSearchParams(navigation.location?.search);
|
||||
const currentEnvironment = new URLSearchParams(location.search).get("environment");
|
||||
const pendingEnvironment = new URLSearchParams(navigation.location?.search).get("environment");
|
||||
|
||||
const isLoadingTasks =
|
||||
navigation.state === "loading" &&
|
||||
navigation.location.pathname === location.pathname &&
|
||||
navigationSearchParams.get("environment") !== locationSearchParams.get("environment");
|
||||
currentEnvironment !== pendingEnvironment;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Test" />
|
||||
<PageAccessories>
|
||||
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/run-tests")}>
|
||||
Test docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className={cn("grid h-full max-h-full grid-cols-1")}>
|
||||
@@ -106,10 +114,19 @@ export default function Page() {
|
||||
"flex h-8 flex-1 items-center justify-center rounded-sm border text-xs uppercase tracking-wider",
|
||||
isSelected
|
||||
? cn(environmentBorderClassName(env), environmentTextClassName(env))
|
||||
: "border-grid-bright text-text-dimmed"
|
||||
: "border-grid-bright text-text-dimmed transition hover:border-charcoal-600 hover:text-text-bright"
|
||||
)}
|
||||
key={env.id}
|
||||
to={v3TestPath(organization, project, env.slug)}
|
||||
to={
|
||||
taskParam
|
||||
? v3TestTaskPath(
|
||||
organization,
|
||||
project,
|
||||
{ taskIdentifier: taskParam },
|
||||
env.slug
|
||||
)
|
||||
: v3TestPath(organization, project, env.slug)
|
||||
}
|
||||
>
|
||||
<span>{environmentTitle(env)}</span>
|
||||
</Link>
|
||||
@@ -122,8 +139,8 @@ export default function Page() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : hasSelectedEnvironment ? (
|
||||
<div className="grid grid-rows-[2rem_1fr] overflow-hidden">
|
||||
<div className="mx-3 flex items-end">
|
||||
<div className="grid grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-end px-3 pt-2">
|
||||
<Header2>Select a task</Header2>
|
||||
</div>
|
||||
{!rest.tasks?.length ? (
|
||||
@@ -132,6 +149,7 @@ export default function Page() {
|
||||
<TaskSelector
|
||||
tasks={rest.tasks}
|
||||
environmentSlug={rest.selectedEnvironment.slug}
|
||||
activeTaskIdentifier={taskParam}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -154,14 +172,24 @@ export default function Page() {
|
||||
function TaskSelector({
|
||||
tasks,
|
||||
environmentSlug,
|
||||
activeTaskIdentifier,
|
||||
}: {
|
||||
tasks: TaskListItem[];
|
||||
environmentSlug: string;
|
||||
activeTaskIdentifier?: string;
|
||||
}) {
|
||||
const { filterText, setFilterText, filteredItems } = useFilterTasks<TaskListItem>({ tasks });
|
||||
const hasTaskInEnvironment = activeTaskIdentifier
|
||||
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div
|
||||
className={cn(
|
||||
"grid max-h-full overflow-hidden",
|
||||
hasTaskInEnvironment === false ? "grid-rows-[auto_auto_1fr]" : "grid-rows-[auto_1fr]"
|
||||
)}
|
||||
>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
@@ -173,13 +201,19 @@ function TaskSelector({
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{hasTaskInEnvironment === false && (
|
||||
<div className="px-2 pb-2">
|
||||
<Callout variant="warning">
|
||||
There is no task {activeTaskIdentifier} in the selected environment.
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="px-2">
|
||||
<span className="sr-only">Go to test task</span>
|
||||
<TableHeaderCell className="pl-3" colSpan={2}>
|
||||
Task
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="px-2">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="px-2">File path</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -204,9 +238,9 @@ function TaskSelector({
|
||||
function NoTaskInstructions({ environment }: { environment?: SelectedEnvironment }) {
|
||||
return (
|
||||
<div className="px-3 py-3">
|
||||
<Paragraph spacing variant="small">
|
||||
<Callout variant="info">
|
||||
You have no tasks {environment ? `in ${environmentTitle(environment)}` : ""}.
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,23 +251,29 @@ function TaskRow({ task, environmentSlug }: { task: TaskListItem; environmentSlu
|
||||
|
||||
const path = v3TestTaskPath(organization, project, task, environmentSlug);
|
||||
const { isActive, isPending } = useLinkStatus(path);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={task.taskIdentifier}
|
||||
className={cn(
|
||||
(isActive || isPending) &&
|
||||
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
|
||||
)}
|
||||
className={cn((isActive || isPending) && "bg-indigo-500/10")}
|
||||
>
|
||||
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
|
||||
<TableCell
|
||||
to={path}
|
||||
actionClassName="pl-2.5 pr-2 py-1"
|
||||
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
||||
>
|
||||
<RadioButtonCircle checked={isActive || isPending} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
|
||||
<TableCell
|
||||
to={path}
|
||||
actionClassName="pl-1 pr-2 py-1.5"
|
||||
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TaskFunctionName
|
||||
variant="extra-small"
|
||||
functionName={task.exportName}
|
||||
className="-ml-1 inline-flex"
|
||||
className="inline-flex w-fit"
|
||||
/>
|
||||
<div className="flex items-start gap-1">
|
||||
<TaskTriggerSourceIcon source={task.triggerSource} className="size-3.5" />
|
||||
@@ -244,7 +284,11 @@ function TaskRow({ task, environmentSlug }: { task: TaskListItem; environmentSlu
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path} actionClassName="px-2 py-1">
|
||||
<TableCell
|
||||
to={path}
|
||||
actionClassName="px-2 py-1"
|
||||
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
|
||||
>
|
||||
{task.filePath}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -209,6 +209,7 @@ export default function Page() {
|
||||
title="Unlock more team members"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
panelClassName="mt-4 max-w-sm"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available team members. Upgrade your plan to
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
@@ -101,13 +102,13 @@ export default function Page() {
|
||||
<NavBar>
|
||||
<PageTitle title="Usage" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<PageBody scrollable={true} className="p-0">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Select
|
||||
name="month"
|
||||
placeholder="Select a month"
|
||||
className="mb-3"
|
||||
className="m-3"
|
||||
defaultValue={month}
|
||||
items={months.map((date) => ({
|
||||
label: monthDateFormatter.format(date),
|
||||
@@ -118,7 +119,7 @@ export default function Page() {
|
||||
replace({ month: value });
|
||||
}}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((month) => (
|
||||
@@ -128,7 +129,7 @@ export default function Page() {
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<div className="flex w-full flex-col gap-2 rounded-sm border border-grid-dimmed p-4">
|
||||
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await
|
||||
resolve={usage}
|
||||
@@ -139,11 +140,11 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
{(usage) => (
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header3 className="whitespace-nowrap">
|
||||
<div className="flex items-end gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header2 className="whitespace-nowrap">
|
||||
{isCurrentMonth ? "Month-to-date" : "Usage"}
|
||||
</Header3>
|
||||
</Header2>
|
||||
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
|
||||
{formatCurrency(usage.overall.current, false)}
|
||||
</p>
|
||||
@@ -164,8 +165,10 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Header2 spacing>Usage by day</Header2>
|
||||
<div className="rounded-sm border border-grid-dimmed p-4">
|
||||
<Header2 spacing className="pl-3">
|
||||
Usage by day
|
||||
</Header2>
|
||||
<div className="p-3">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
@@ -187,7 +190,9 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Header2 spacing>Tasks</Header2>
|
||||
<Header2 spacing className="pl-3">
|
||||
Tasks
|
||||
</Header2>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await
|
||||
resolve={tasks}
|
||||
@@ -215,8 +220,10 @@ export default function Page() {
|
||||
{tasks.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph variant="small">No runs for this period</Paragraph>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Paragraph variant="base/bright">
|
||||
No runs for this period
|
||||
</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -248,7 +255,11 @@ export default function Page() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<InfoPanel icon={InformationCircleIcon} panelClassName="max-w-[22rem] mt-3">
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
variant="minimal"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
Dev environment runs are excluded from the usage data above, since they do
|
||||
not have an associated compute cost.
|
||||
</InfoPanel>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { getCurrentPlan, getUsage } from "~/services/platform.v3.server";
|
||||
import { getCachedUsage, getCurrentPlan, getUsage } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { organizationPath } from "~/utils/pathBuilder";
|
||||
@@ -29,6 +29,27 @@ export function useCurrentPlan(matches?: UIMatch[]) {
|
||||
return data?.currentPlan;
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
|
||||
const { currentParams, nextParams } = params;
|
||||
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// This prevents revalidation when there are search params changes
|
||||
// IMPORTANT: If the loader function depends on search params, this should be updated
|
||||
return params.currentUrl.pathname !== params.nextUrl.pathname;
|
||||
};
|
||||
|
||||
// IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -50,11 +71,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const firstDayOfMonth = new Date();
|
||||
firstDayOfMonth.setUTCDate(1);
|
||||
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setUTCDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Using the 1st day of next month means we get the usage for the current month
|
||||
// and the cache key for getCachedUsage is stable over the month
|
||||
const firstDayOfNextMonth = new Date();
|
||||
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
|
||||
firstDayOfNextMonth.setUTCDate(1);
|
||||
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [plan, usage] = await Promise.all([
|
||||
getCurrentPlan(organization.id),
|
||||
getUsage(organization.id, { from: firstDayOfMonth, to: tomorrow }),
|
||||
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
|
||||
]);
|
||||
|
||||
let hasExceededFreeTier = false;
|
||||
@@ -100,26 +127,6 @@ export function ErrorBoundary() {
|
||||
return org ? (
|
||||
<RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />
|
||||
) : (
|
||||
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
|
||||
<RouteErrorDisplay button={{ title: "Go to homepage", to: "/" }} />
|
||||
);
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
defaultShouldRevalidate,
|
||||
currentParams,
|
||||
nextParams,
|
||||
}) => {
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon, ShieldCheckIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useActionData, useFetcher } from "@remix-run/react";
|
||||
import { ActionFunction, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
@@ -27,10 +28,12 @@ import {
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
CreatedPersonalAccessToken,
|
||||
@@ -40,7 +43,7 @@ import {
|
||||
revokePersonalAccessToken,
|
||||
} from "~/services/personalAccessToken.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { personalAccessTokensPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, personalAccessTokensPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -127,6 +130,13 @@ export default function Page() {
|
||||
<NavBar>
|
||||
<PageTitle title="Personal Access Tokens" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("management/overview#personal-access-token-pat")}
|
||||
variant="docs/small"
|
||||
>
|
||||
Personal Access Token docs
|
||||
</LinkButton>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small">Create new token</Button>
|
||||
@@ -139,9 +149,9 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Table>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full grid-rows-1">
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
@@ -168,15 +178,19 @@ export default function Page() {
|
||||
"Never"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell alignment="right">
|
||||
<RevokePersonalAccessToken token={personalAccessToken} />
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
visibleButtons={<RevokePersonalAccessToken token={personalAccessToken} />}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={5}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
<Paragraph
|
||||
variant="base/bright"
|
||||
className="flex items-center justify-center py-8"
|
||||
>
|
||||
You have no Personal Access Tokens (that haven't been revoked).
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
@@ -249,6 +263,11 @@ function CreatePersonalAccessToken() {
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
@@ -270,25 +289,44 @@ function RevokePersonalAccessToken({ token }: { token: ObfuscatedPersonalAccessT
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="small-menu-item" LeadingIcon="trash-can" className="text-xs" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Revoke Personal Access Token</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
Are you sure you want to revoke "{token.name}"? This can't be reversed.
|
||||
</Paragraph>
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="revoke" />
|
||||
<input type="hidden" name="tokenId" value={token.id} />
|
||||
<Button type="submit" variant="danger/medium" fullWidth>
|
||||
Revoke token
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-error transition hover:bg-charcoal-700"
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Revoke Personal Access Token</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph spacing>
|
||||
Are you sure you want to revoke "{token.name}"? This can't be reversed.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="revoke" />
|
||||
<input type="hidden" name="tokenId" value={token.id} />
|
||||
<Button type="submit" variant="danger/medium">
|
||||
Revoke token
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
content="Revoke token…"
|
||||
side="left"
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
export async function action({ request }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const claims = {
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
};
|
||||
|
||||
return json(claims);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { z } from "zod";
|
||||
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
|
||||
|
||||
const RequestBodySchema = z.object({
|
||||
claims: z
|
||||
.object({
|
||||
scopes: z.array(z.string()).default([]),
|
||||
})
|
||||
.optional(),
|
||||
expirationTime: z.union([z.number(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
export async function action({ request }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedBody = RequestBodySchema.safeParse(await request.json());
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return json(
|
||||
{ error: "Invalid request body", issues: parsedBody.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const claims = {
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
...parsedBody.data.claims,
|
||||
};
|
||||
|
||||
const jwt = await internal_generateJWT({
|
||||
secretKey: authenticationResult.apiKey,
|
||||
payload: claims,
|
||||
expirationTime: parsedBody.data.expirationTime ?? "1h",
|
||||
});
|
||||
|
||||
return json({ token: jwt });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: (params, auth) => {
|
||||
return $replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ resource: batch }) => {
|
||||
return json({
|
||||
id: batch.friendlyId,
|
||||
status: batch.status,
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
createdAt: batch.createdAt,
|
||||
updatedAt: batch.updatedAt,
|
||||
runCount: batch.runCount,
|
||||
});
|
||||
}
|
||||
);
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
id: parsedParams.data.connectionId,
|
||||
integration: {
|
||||
slug: parsedParams.data.integrationSlug,
|
||||
organization: authenticatedEnv.organization,
|
||||
organizationId: authenticatedEnv.organization.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { generatePresignedUrl } from "~/v3/r2.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -39,28 +40,28 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ presignedUrl });
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: ActionFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
},
|
||||
async ({ params, authentication }) => {
|
||||
const filename = params["*"];
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
const presignedUrl = await generatePresignedUrl(
|
||||
authentication.environment.project.externalRef,
|
||||
authentication.environment.slug,
|
||||
filename,
|
||||
"GET"
|
||||
);
|
||||
|
||||
if (!presignedUrl) {
|
||||
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Caller can now use this URL to fetch that object.
|
||||
return json({ presignedUrl });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.parse(params);
|
||||
const filename = parsedParams["*"];
|
||||
|
||||
const presignedUrl = await generatePresignedUrl(
|
||||
authenticationResult.environment.project.externalRef,
|
||||
authenticationResult.environment.slug,
|
||||
filename,
|
||||
"GET"
|
||||
);
|
||||
|
||||
if (!presignedUrl) {
|
||||
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Caller can now use this URL to fetch that object.
|
||||
return json({ presignedUrl });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,62 +1,36 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ValidationError } from "zod-validation-error";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import {
|
||||
ApiRunListPresenter,
|
||||
ApiRunListSearchParams,
|
||||
} from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
export const loader = createLoaderPATApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ searchParams, params, authentication }) => {
|
||||
const project = await findProjectByRef(params.projectRef, authentication.userId);
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const $params = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!$params.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectByRef($params.data.projectRef, authenticationResult.userId);
|
||||
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const presenter = new ApiRunListPresenter();
|
||||
|
||||
try {
|
||||
const result = await presenter.call(project, url.searchParams);
|
||||
const presenter = new ApiRunListPresenter();
|
||||
const result = await presenter.call(project, searchParams);
|
||||
|
||||
if (!result) {
|
||||
return apiCors(request, json({ data: [] }));
|
||||
return json({ data: [] });
|
||||
}
|
||||
|
||||
return apiCors(request, json(result));
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Query Error", details: error.details }, { status: 400 })
|
||||
);
|
||||
} else {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
);
|
||||
}
|
||||
return json(result);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -89,6 +89,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
tags: {
|
||||
connect: tagIds.map((id) => ({ id })),
|
||||
},
|
||||
runTags: {
|
||||
push: newTags,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const service = new CreateTaskRunAttemptService();
|
||||
|
||||
try {
|
||||
const { execution } = await service.call(runParam, authenticationResult.environment);
|
||||
const { execution } = await service.call({
|
||||
runId: runParam,
|
||||
authenticatedEnv: authenticationResult.environment,
|
||||
});
|
||||
|
||||
return json(execution, { status: 200 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -61,13 +61,18 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "An unknown error occurred" }, { status: 500 });
|
||||
}
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(
|
||||
const run = await ApiRetrieveRunPresenter.findRun(
|
||||
updatedRun.friendlyId,
|
||||
authenticationResult.environment,
|
||||
true
|
||||
authenticationResult.environment
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(run, authenticationResult.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user