Compare commits
243 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5730faea00 | |||
| 58f5955c66 | |||
| 7162f07216 | |||
| 8f3a28effb | |||
| bbd82adbda | |||
| edf0c1024f | |||
| 457273e4cf | |||
| c315e38d76 | |||
| e0241819a4 | |||
| 796e710439 | |||
| c8732becbd | |||
| 5164935f9c | |||
| e1db12b8ba | |||
| 187200a1bc | |||
| 26f9a1e478 | |||
| 0e5ec8bfbc | |||
| d2ceb825a7 | |||
| 376a0c5d04 | |||
| 06f60f2720 | |||
| ca05a544d5 | |||
| 8044433320 | |||
| 412eb81ee1 | |||
| 62e3238ecc | |||
| 5a250cf92d | |||
| 80f2fc8bbe | |||
| bf43fd4f8e | |||
| 7186b1e428 | |||
| 99f2caf5c2 | |||
| e19bf2abaf | |||
| 23b4c2a214 | |||
| 7b1159eb45 | |||
| c9f7ea87c3 | |||
| d6c869eb8f | |||
| 31a43273df | |||
| b4d1619732 | |||
| 1e194a53e5 | |||
| 8f3f373002 | |||
| 36fea7d645 | |||
| 37723344cd | |||
| e61b29ae52 | |||
| 4dbf217edb | |||
| a62cb3e60b | |||
| 440d413ce8 | |||
| a2c70b450a | |||
| a38f713e32 | |||
| 0924b37283 | |||
| d622aba86d | |||
| d214aa9093 | |||
| bd0cc541c5 | |||
| f0726affa2 | |||
| 0ceb37f0e8 | |||
| 252bf8d806 | |||
| efd2d21762 | |||
| 6017c522e1 | |||
| a50b93faa4 | |||
| d4043fc258 | |||
| 6ec786c7b2 | |||
| d2ee232fae | |||
| 5f652c6212 | |||
| 036a5061e1 | |||
| bb65b2614a | |||
| 4dd42cd6d4 | |||
| bc7d44582d | |||
| 2771fd7408 | |||
| 1f1a6b0405 | |||
| 9f843d0a0f | |||
| 9dfd6a5a0a | |||
| 5210d3a8bb | |||
| b5867627be | |||
| 0b555faf01 | |||
| dcf1ab6f38 | |||
| c49af774ba | |||
| f00ed9b13e | |||
| 3ec6983e45 | |||
| a2c2d920b7 | |||
| b946b9f38e | |||
| 99d38151f6 | |||
| e35b6f539a | |||
| 20d983e08a | |||
| 3887c4a621 | |||
| e61573600b | |||
| 53e6d478ed | |||
| 7fa45ad337 | |||
| 42c5f4d9fa | |||
| 6f52b00560 | |||
| d285f7930e | |||
| b377b577ca | |||
| b67ea9a2f5 | |||
| de31220820 | |||
| 3641647add | |||
| 664bce561d | |||
| 73471589ef | |||
| 813d73d1a3 | |||
| 9ad396bdb1 | |||
| 4e7c55920d | |||
| 3eede3c339 | |||
| b3afbcf558 | |||
| f43de6ab74 | |||
| 3127df5b67 | |||
| 9fd175e860 | |||
| ef0fc6b0b4 | |||
| a24b2a7732 | |||
| 6da5e7a4cf | |||
| 75753ec5ad | |||
| 40e0d2f7e9 | |||
| 246eb4361e | |||
| ab6e6afbf5 | |||
| 7445d9bf35 | |||
| f6eeaa1001 | |||
| eaf46bac58 | |||
| 16cf357380 | |||
| f96bf7210b | |||
| c855e4a96c | |||
| e1497c802d | |||
| e2612224b4 | |||
| 0d38ea0feb | |||
| 0687ce722c | |||
| d627fa718e | |||
| 5798024a5b | |||
| 5750f35add | |||
| 11f2832ae5 | |||
| fd04ed50ef | |||
| 6d17443e16 | |||
| dd321e3616 | |||
| 2ad664d17d | |||
| 5eb30d026d | |||
| 719ae83057 | |||
| 7eda22851e | |||
| b3aa87ee26 | |||
| 14ce599218 | |||
| 0a18e1d919 | |||
| ca51f65394 | |||
| 46a37fdc49 | |||
| 24383dc65d | |||
| e13edd3a20 | |||
| 9e0f03623d | |||
| 914ceaf3c5 | |||
| 472e5f93e1 | |||
| 51a054f534 | |||
| efd61f1374 | |||
| 2c7f7b1d5e | |||
| c92edd61af | |||
| ab69b60348 | |||
| d52e3d7d57 | |||
| 3ca0c15161 | |||
| 0aa597104c | |||
| 34f8bd588e | |||
| 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 |
+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.
|
||||
@@ -39,4 +39,4 @@ jobs:
|
||||
${{ runner.os }}-mintlify
|
||||
|
||||
- name: 🔗 Check for broken links
|
||||
run: npx mintlify@4.0.222 broken-links
|
||||
run: npx mintlify@4.0.393 broken-links
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [buildjet-8vcpu-ubuntu-2204, windows-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
package-manager: ["npm", "pnpm"]
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
|
||||
@@ -28,34 +28,3 @@ jobs:
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
preview-release:
|
||||
name: Preview Release
|
||||
needs: [typecheck, units, e2e]
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.15.5
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.11.1
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🏗️ Build
|
||||
run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
|
||||
|
||||
- name: ⚡ Publish preview release
|
||||
run: npx pkg-pr-new publish --no-template $(ls -d ./packages/*)
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
package: [coordinator, docker-provider, kubernetes-provider]
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
steps:
|
||||
|
||||
@@ -24,12 +24,10 @@ on:
|
||||
- "packages/**"
|
||||
- "!packages/**/*.md"
|
||||
- "!packages/**/*.eslintrc"
|
||||
- "internal-packages/**"
|
||||
- "apps/**"
|
||||
- "!apps/**/*.md"
|
||||
- "!apps/**/*.eslintrc"
|
||||
- "integrations/**"
|
||||
- "!integrations/**/*.md"
|
||||
- "!integrations/**/*.eslintrc"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
unitTests:
|
||||
name: "🧪 Unit Tests"
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+34
@@ -13,6 +13,24 @@
|
||||
"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": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug fairDequeuingStrategy.test.ts",
|
||||
"command": "pnpm run test -t FairDequeuingStrategy",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"cwd": "${workspaceFolder}/apps/webapp",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
@@ -28,6 +46,14 @@
|
||||
"cwd": "${workspaceFolder}/references/init-shell",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug V3 init dev CLI",
|
||||
"command": "pnpm exec trigger dev",
|
||||
"cwd": "${workspaceFolder}/references/init-shell",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
@@ -36,6 +62,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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
### Open source background jobs with no timeouts
|
||||
### Open source background jobs and AI infrastructure
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
|
||||
|
||||
@@ -20,12 +20,14 @@ Trigger.dev is an open source platform and SDK which allows you to create long-r
|
||||
### Key features:
|
||||
|
||||
- JavaScript and TypeScript SDK
|
||||
- Write reliable code by default
|
||||
- No timeouts
|
||||
- Retries (with exponential backoff)
|
||||
- Queues and concurrency controls
|
||||
- Schedules and crons
|
||||
- Full Observability; logs, live trace views, advanced filtering
|
||||
- React hooks to interact with the Trigger API from your React app
|
||||
- Pipe LLM streams straight to your users through the Realtime API
|
||||
- Trigger tasks and display the run status and metadata anywhere in your app
|
||||
- Custom alerts, get notified by email, Slack or webhooks
|
||||
- No infrastructure to manage
|
||||
- Elastic (scaling)
|
||||
|
||||
@@ -13,7 +13,7 @@ RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
|
||||
FROM node-20 AS base
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init docker.io \
|
||||
&& apt-get install -y buildah ca-certificates dumb-init docker.io busybox \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chown=node:node .gitignore .gitignore
|
||||
|
||||
@@ -27,7 +27,7 @@ type CheckpointAndPushResult =
|
||||
| { success: true; checkpoint: CheckpointData }
|
||||
| {
|
||||
success: false;
|
||||
reason?: "CANCELED" | "DISABLED" | "ERROR" | "IN_PROGRESS" | "NO_SUPPORT" | "SKIP_RETRYING";
|
||||
reason?: "CANCELED" | "ERROR" | "SKIP_RETRYING";
|
||||
};
|
||||
|
||||
type CheckpointData = {
|
||||
@@ -87,9 +87,14 @@ export class Checkpointer {
|
||||
#dockerMode: boolean;
|
||||
|
||||
#logger = new SimpleStructuredLogger("checkpointer");
|
||||
#abortControllers = new Map<string, AbortController>();
|
||||
|
||||
#failedCheckpoints = new Map<string, unknown>();
|
||||
#waitingForRetry = new Set<string>();
|
||||
|
||||
// Indexed by run ID
|
||||
#runAbortControllers = new Map<
|
||||
string,
|
||||
{ signal: AbortSignal; abort: AbortController["abort"] }
|
||||
>();
|
||||
|
||||
private registryHost: string;
|
||||
private registryNamespace: string;
|
||||
@@ -189,29 +194,80 @@ export class Checkpointer {
|
||||
}
|
||||
}
|
||||
|
||||
async checkpointAndPush(opts: CheckpointAndPushOptions): Promise<CheckpointData | undefined> {
|
||||
async checkpointAndPush(
|
||||
opts: CheckpointAndPushOptions,
|
||||
delayMs?: number
|
||||
): Promise<CheckpointData | undefined> {
|
||||
const start = performance.now();
|
||||
this.#logger.log(`checkpointAndPush() start`, { start, opts });
|
||||
|
||||
let interval: NodeJS.Timer | undefined;
|
||||
const { runId } = opts;
|
||||
|
||||
let interval: NodeJS.Timer | undefined;
|
||||
if (opts.shouldHeartbeat) {
|
||||
interval = setInterval(() => {
|
||||
this.#logger.log("Sending heartbeat", { runId: opts.runId });
|
||||
this.opts.heartbeat(opts.runId);
|
||||
this.#logger.log("Sending heartbeat", { runId });
|
||||
this.opts.heartbeat(runId);
|
||||
}, 20_000);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const abort = controller.abort.bind(controller);
|
||||
|
||||
const onAbort = () => {
|
||||
this.#logger.error("Checkpoint aborted", { runId, options: opts });
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const removeCurrentAbortController = () => {
|
||||
const controller = this.#runAbortControllers.get(runId);
|
||||
|
||||
// Ensure only the current controller is removed
|
||||
if (controller && controller.signal === signal) {
|
||||
this.#runAbortControllers.delete(runId);
|
||||
}
|
||||
|
||||
// Remove the abort listener in case it hasn't fired
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
if (!this.#dockerMode && !this.#canCheckpoint) {
|
||||
this.#logger.error("No checkpoint support. Simulation requires docker.");
|
||||
this.#failCheckpoint(runId, "NO_SUPPORT");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isRunCheckpointing(runId)) {
|
||||
this.#logger.error("Checkpoint procedure already in progress", { options: opts });
|
||||
this.#failCheckpoint(runId, "IN_PROGRESS");
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a new checkpoint, clear any last failure for this run
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
|
||||
if (this.disableCheckpointSupport) {
|
||||
this.#logger.error("Checkpoint support disabled", { options: opts });
|
||||
this.#failCheckpoint(runId, "DISABLED");
|
||||
return;
|
||||
}
|
||||
|
||||
this.#runAbortControllers.set(runId, { signal, abort });
|
||||
|
||||
try {
|
||||
const result = await this.#checkpointAndPushWithBackoff(opts);
|
||||
const result = await this.#checkpointAndPushWithBackoff(opts, { delayMs, signal });
|
||||
|
||||
const end = performance.now();
|
||||
this.#logger.log(`checkpointAndPush() end`, {
|
||||
start,
|
||||
end,
|
||||
diff: end - start,
|
||||
diffWithoutDelay: end - start - (delayMs ?? 0),
|
||||
opts,
|
||||
success: result.success,
|
||||
delayMs,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -223,51 +279,66 @@ export class Checkpointer {
|
||||
if (opts.shouldHeartbeat) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
removeCurrentAbortController();
|
||||
}
|
||||
}
|
||||
|
||||
isCheckpointing(runId: string) {
|
||||
return this.#abortControllers.has(runId) || this.#waitingForRetry.has(runId);
|
||||
#isRunCheckpointing(runId: string) {
|
||||
return this.#runAbortControllers.has(runId);
|
||||
}
|
||||
|
||||
cancelCheckpoint(runId: string): boolean {
|
||||
cancelAllCheckpointsForRun(runId: string): boolean {
|
||||
this.#logger.log("cancelAllCheckpointsForRun: call", { runId });
|
||||
|
||||
// If the last checkpoint failed, pretend we canceled it
|
||||
// This ensures tasks don't wait for external resume messages to continue
|
||||
if (this.#hasFailedCheckpoint(runId)) {
|
||||
this.#logger.log("cancelAllCheckpointsForRun: hasFailedCheckpoint", { runId });
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.#waitingForRetry.has(runId)) {
|
||||
this.#waitingForRetry.delete(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const controller = this.#abortControllers.get(runId);
|
||||
const controller = this.#runAbortControllers.get(runId);
|
||||
|
||||
if (!controller) {
|
||||
this.#logger.debug("Nothing to cancel", { runId });
|
||||
this.#logger.debug("cancelAllCheckpointsForRun: no abort controller", { runId });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
this.#logger.debug("Controller already aborted", { runId });
|
||||
const { abort, signal } = controller;
|
||||
|
||||
if (signal.aborted) {
|
||||
this.#logger.debug("cancelAllCheckpointsForRun: signal already aborted", { runId });
|
||||
return false;
|
||||
}
|
||||
|
||||
controller.abort("cancelCheckpoint()");
|
||||
this.#abortControllers.delete(runId);
|
||||
abort("cancelCheckpoint()");
|
||||
this.#runAbortControllers.delete(runId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #checkpointAndPushWithBackoff({
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
|
||||
async #checkpointAndPushWithBackoff(
|
||||
{
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
}: CheckpointAndPushOptions,
|
||||
{ delayMs, signal }: { delayMs?: number; signal: AbortSignal }
|
||||
): Promise<CheckpointAndPushResult> {
|
||||
if (delayMs && delayMs > 0) {
|
||||
this.#logger.log("Delaying checkpoint", { runId, delayMs });
|
||||
|
||||
try {
|
||||
await setTimeout(delayMs, undefined, { signal });
|
||||
} catch (error) {
|
||||
this.#logger.log("Checkpoint canceled during initial delay", { runId });
|
||||
return { success: false, reason: "CANCELED" };
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.log("Checkpointing with backoff", {
|
||||
runId,
|
||||
leaveRunning,
|
||||
@@ -290,24 +361,24 @@ export class Checkpointer {
|
||||
delay,
|
||||
});
|
||||
|
||||
this.#waitingForRetry.add(runId);
|
||||
await setTimeout(delay.milliseconds);
|
||||
|
||||
if (!this.#waitingForRetry.has(runId)) {
|
||||
this.#logger.log("Checkpoint canceled while waiting for retry", { runId });
|
||||
try {
|
||||
await setTimeout(delay.milliseconds, undefined, { signal });
|
||||
} catch (error) {
|
||||
this.#logger.log("Checkpoint canceled during retry delay", { runId });
|
||||
return { success: false, reason: "CANCELED" };
|
||||
} else {
|
||||
this.#waitingForRetry.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.#checkpointAndPush({
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
});
|
||||
const result = await this.#checkpointAndPush(
|
||||
{
|
||||
runId,
|
||||
leaveRunning,
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
return result;
|
||||
@@ -319,24 +390,6 @@ export class Checkpointer {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "IN_PROGRESS") {
|
||||
this.#logger.log("Checkpoint already in progress, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "NO_SUPPORT") {
|
||||
this.#logger.log("No checkpoint support, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "DISABLED") {
|
||||
this.#logger.log("Checkpoint support disabled, won't retry", { runId });
|
||||
this.#failCheckpoint(runId, result.reason);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.reason === "SKIP_RETRYING") {
|
||||
this.#logger.log("Skipping retrying", { runId });
|
||||
return result;
|
||||
@@ -364,13 +417,16 @@ export class Checkpointer {
|
||||
return { success: false, reason: "ERROR" };
|
||||
}
|
||||
|
||||
async #checkpointAndPush({
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
}: CheckpointAndPushOptions): Promise<CheckpointAndPushResult> {
|
||||
async #checkpointAndPush(
|
||||
{
|
||||
runId,
|
||||
leaveRunning = true, // This mirrors kubernetes behaviour more accurately
|
||||
projectRef,
|
||||
deploymentVersion,
|
||||
attemptNumber,
|
||||
}: CheckpointAndPushOptions,
|
||||
{ signal }: { signal: AbortSignal }
|
||||
): Promise<CheckpointAndPushResult> {
|
||||
await this.init();
|
||||
|
||||
const options = {
|
||||
@@ -381,39 +437,12 @@ export class Checkpointer {
|
||||
attemptNumber,
|
||||
};
|
||||
|
||||
if (!this.#dockerMode && !this.#canCheckpoint) {
|
||||
this.#logger.error("No checkpoint support. Simulation requires docker.");
|
||||
return { success: false, reason: "NO_SUPPORT" };
|
||||
}
|
||||
|
||||
if (this.isCheckpointing(runId)) {
|
||||
this.#logger.error("Checkpoint procedure already in progress", { options });
|
||||
return { success: false, reason: "IN_PROGRESS" };
|
||||
}
|
||||
|
||||
// This is a new checkpoint, clear any last failure for this run
|
||||
this.#clearFailedCheckpoint(runId);
|
||||
|
||||
if (this.disableCheckpointSupport) {
|
||||
this.#logger.error("Checkpoint support disabled", { options });
|
||||
return { success: false, reason: "DISABLED" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortControllers.set(runId, controller);
|
||||
|
||||
const onAbort = () => {
|
||||
this.#logger.error("Checkpoint aborted", { options });
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
controller.signal.addEventListener("abort", onAbort);
|
||||
|
||||
const shortCode = nanoid(8);
|
||||
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
|
||||
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
|
||||
|
||||
const buildah = new Buildah({ id: `${runId}-${shortCode}`, abortSignal: controller.signal });
|
||||
const crictl = new Crictl({ id: `${runId}-${shortCode}`, abortSignal: controller.signal });
|
||||
const buildah = new Buildah({ id: `${runId}-${shortCode}`, abortSignal: signal });
|
||||
const crictl = new Crictl({ id: `${runId}-${shortCode}`, abortSignal: signal });
|
||||
|
||||
const cleanup = async () => {
|
||||
const metadata = {
|
||||
@@ -435,32 +464,26 @@ export class Checkpointer {
|
||||
} catch (error) {
|
||||
this.#logger.error("Error during cleanup", { ...metadata, error });
|
||||
}
|
||||
|
||||
// Ensure only the current controller is removed
|
||||
if (this.#abortControllers.get(runId) === controller) {
|
||||
this.#abortControllers.delete(runId);
|
||||
}
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
try {
|
||||
await this.chaosMonkey.call();
|
||||
|
||||
this.#logger.log("Checkpointing:", { options });
|
||||
this.#logger.log("checkpointAndPush: checkpointing", { options });
|
||||
|
||||
const containterName = this.#getRunContainerName(runId);
|
||||
|
||||
// Create checkpoint (docker)
|
||||
if (this.#dockerMode) {
|
||||
await this.#createDockerCheckpoint(
|
||||
controller.signal,
|
||||
signal,
|
||||
runId,
|
||||
exportLocation,
|
||||
leaveRunning,
|
||||
attemptNumber
|
||||
);
|
||||
|
||||
this.#logger.log("checkpoint created:", {
|
||||
this.#logger.log("checkpointAndPush: checkpoint created", {
|
||||
runId,
|
||||
location: exportLocation,
|
||||
});
|
||||
@@ -561,13 +584,16 @@ export class Checkpointer {
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.error("Unhandled checkpoint error", { options, error });
|
||||
this.#logger.error("Unhandled checkpoint error", {
|
||||
options,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
|
||||
return { success: false, reason: "ERROR" };
|
||||
} finally {
|
||||
await cleanup();
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
if (signal.aborted) {
|
||||
this.#logger.error("Checkpoint canceled: Cleanup", { options });
|
||||
|
||||
// Overrides any prior return value
|
||||
@@ -576,6 +602,16 @@ export class Checkpointer {
|
||||
}
|
||||
}
|
||||
|
||||
async unpause(runId: string, attemptNumber?: number): Promise<void> {
|
||||
try {
|
||||
const containterNameWithAttempt = this.#getRunContainerName(runId, attemptNumber);
|
||||
const exec = new Exec({ logger: this.#logger });
|
||||
await exec.x("docker", ["unpause", containterNameWithAttempt]);
|
||||
} catch (error) {
|
||||
this.#logger.error("[Docker] Error during unpause", { runId, attemptNumber, error });
|
||||
}
|
||||
}
|
||||
|
||||
async #createDockerCheckpoint(
|
||||
abortSignal: AbortSignal,
|
||||
runId: string,
|
||||
|
||||
+434
-55
@@ -11,10 +11,10 @@ import {
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { ZodNamespace } from "@trigger.dev/core/v3/zodNamespace";
|
||||
import { ZodSocketConnection } from "@trigger.dev/core/v3/zodSocket";
|
||||
import { HttpReply, getTextBody } from "@trigger.dev/core/v3/apps";
|
||||
import { ExponentialBackoff, HttpReply, getTextBody } from "@trigger.dev/core/v3/apps";
|
||||
import { ChaosMonkey } from "./chaosMonkey";
|
||||
import { Checkpointer } from "./checkpointer";
|
||||
import { boolFromEnv, numFromEnv } from "./util";
|
||||
import { boolFromEnv, numFromEnv, safeJsonParse } from "./util";
|
||||
|
||||
import { collectDefaultMetrics, register, Gauge } from "prom-client";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
@@ -30,6 +30,16 @@ const PLATFORM_WS_PORT = process.env.PLATFORM_WS_PORT || 3030;
|
||||
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || "coordinator-secret";
|
||||
const SECURE_CONNECTION = ["1", "true"].includes(process.env.SECURE_CONNECTION ?? "false");
|
||||
|
||||
const TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS =
|
||||
parseInt(process.env.TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS || "") || 30_000;
|
||||
const TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES =
|
||||
parseInt(process.env.TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES || "") || 7;
|
||||
|
||||
const WAIT_FOR_TASK_CHECKPOINT_DELAY_MS =
|
||||
parseInt(process.env.WAIT_FOR_TASK_CHECKPOINT_DELAY_MS || "") || 0;
|
||||
const WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS =
|
||||
parseInt(process.env.WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS || "") || 0;
|
||||
|
||||
const logger = new SimpleStructuredLogger("coordinator", undefined, { nodeName: NODE_NAME });
|
||||
const chaosMonkey = new ChaosMonkey(
|
||||
!!process.env.CHAOS_MONKEY_ENABLED,
|
||||
@@ -42,6 +52,8 @@ class CheckpointCancelError extends Error {}
|
||||
|
||||
class TaskCoordinator {
|
||||
#httpServer: ReturnType<typeof createServer>;
|
||||
#internalHttpServer: ReturnType<typeof createServer>;
|
||||
|
||||
#checkpointer = new Checkpointer({
|
||||
dockerMode: !process.env.KUBERNETES_PORT,
|
||||
forceSimulate: boolFromEnv("FORCE_CHECKPOINT_SIMULATION", false),
|
||||
@@ -79,6 +91,8 @@ class TaskCoordinator {
|
||||
private host = "0.0.0.0"
|
||||
) {
|
||||
this.#httpServer = this.#createHttpServer();
|
||||
this.#internalHttpServer = this.#createInternalHttpServer();
|
||||
|
||||
this.#checkpointer.init();
|
||||
this.#platformSocket = this.#createPlatformSocket();
|
||||
|
||||
@@ -134,6 +148,7 @@ class TaskCoordinator {
|
||||
authToken: PLATFORM_SECRET,
|
||||
logHandlerPayloads: false,
|
||||
handlers: {
|
||||
// This is used by resumeAttempt
|
||||
RESUME_AFTER_DEPENDENCY: async (message) => {
|
||||
const log = platformLogger.child({
|
||||
eventName: "RESUME_AFTER_DEPENDENCY",
|
||||
@@ -159,11 +174,15 @@ class TaskCoordinator {
|
||||
|
||||
await chaosMonkey.call();
|
||||
|
||||
// In case the task resumed faster than we could checkpoint
|
||||
this.#cancelCheckpoint(message.runId);
|
||||
// In case the task resumes before the checkpoint is created
|
||||
this.#cancelCheckpoint(message.runId, {
|
||||
event: "RESUME_AFTER_DEPENDENCY",
|
||||
completions: message.completions.length,
|
||||
});
|
||||
|
||||
taskSocket.emit("RESUME_AFTER_DEPENDENCY", message);
|
||||
},
|
||||
// This is used by sharedQueueConsumer
|
||||
RESUME_AFTER_DEPENDENCY_WITH_ACK: async (message) => {
|
||||
const log = platformLogger.child({
|
||||
eventName: "RESUME_AFTER_DEPENDENCY_WITH_ACK",
|
||||
@@ -209,8 +228,11 @@ class TaskCoordinator {
|
||||
|
||||
await chaosMonkey.call();
|
||||
|
||||
// In case the task resumed faster than we could checkpoint
|
||||
this.#cancelCheckpoint(message.runId);
|
||||
// In case the task resumes before the checkpoint is created
|
||||
this.#cancelCheckpoint(message.runId, {
|
||||
event: "RESUME_AFTER_DEPENDENCY_WITH_ACK",
|
||||
completions: message.completions.length,
|
||||
});
|
||||
|
||||
taskSocket.emit("RESUME_AFTER_DEPENDENCY", message);
|
||||
|
||||
@@ -278,7 +300,7 @@ class TaskCoordinator {
|
||||
log.addFields({ socketId: taskSocket.id, socketData: taskSocket.data });
|
||||
log.log("Found task socket for REQUEST_RUN_CANCELLATION");
|
||||
|
||||
this.#cancelCheckpoint(message.runId);
|
||||
this.#cancelCheckpoint(message.runId, { event: "REQUEST_RUN_CANCELLATION", ...message });
|
||||
|
||||
if (message.delayInMs) {
|
||||
taskSocket.emit("REQUEST_EXIT", {
|
||||
@@ -641,9 +663,25 @@ class TaskCoordinator {
|
||||
|
||||
await chaosMonkey.call();
|
||||
|
||||
const lazyPayload = {
|
||||
...lazyAttempt.lazyPayload,
|
||||
metrics: [
|
||||
...(message.startTime
|
||||
? [
|
||||
{
|
||||
name: "start",
|
||||
event: "lazy_payload",
|
||||
timestamp: message.startTime,
|
||||
duration: Date.now() - message.startTime,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", {
|
||||
version: "v1",
|
||||
lazyPayload: lazyAttempt.lazyPayload,
|
||||
lazyPayload,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChaosMonkey.Error) {
|
||||
@@ -653,11 +691,11 @@ class TaskCoordinator {
|
||||
|
||||
log.error("READY_FOR_LAZY_ATTEMPT error", { error });
|
||||
|
||||
await crashRun({
|
||||
name: "ReadyForLazyAttemptError",
|
||||
message:
|
||||
error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
|
||||
});
|
||||
// await crashRun({
|
||||
// name: "ReadyForLazyAttemptError",
|
||||
// message:
|
||||
// error instanceof Error ? `Unexpected error: ${error.message}` : "Unexpected error",
|
||||
// });
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -712,23 +750,91 @@ class TaskCoordinator {
|
||||
const { completion, execution } = message;
|
||||
|
||||
// Cancel all in-progress checkpoints (if any)
|
||||
this.#cancelCheckpoint(socket.data.runId);
|
||||
this.#cancelCheckpoint(socket.data.runId, {
|
||||
event: "TASK_RUN_COMPLETED",
|
||||
attemptNumber: execution.attempt.number,
|
||||
});
|
||||
|
||||
await chaosMonkey.call({ throwErrors: false });
|
||||
|
||||
const completeWithoutCheckpoint = (shouldExit: boolean) => {
|
||||
const sendCompletionWithAck = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await this.#platformSocket?.sendWithAck(
|
||||
"TASK_RUN_COMPLETED_WITH_ACK",
|
||||
{
|
||||
version: "v2",
|
||||
execution,
|
||||
completion,
|
||||
},
|
||||
TASK_RUN_COMPLETED_WITH_ACK_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
log.error("TASK_RUN_COMPLETED_WITH_ACK: no response");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
log.error("TASK_RUN_COMPLETED_WITH_ACK: error response", {
|
||||
error: response.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
log.log("TASK_RUN_COMPLETED_WITH_ACK: successful response");
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error("TASK_RUN_COMPLETED_WITH_ACK: threw error", { error });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const completeWithoutCheckpoint = async (shouldExit: boolean) => {
|
||||
const supportsRetryCheckpoints = message.version === "v1";
|
||||
|
||||
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
|
||||
version: supportsRetryCheckpoints ? "v1" : "v2",
|
||||
execution,
|
||||
completion,
|
||||
});
|
||||
callback({ willCheckpointAndRestore: false, shouldExit });
|
||||
|
||||
if (supportsRetryCheckpoints) {
|
||||
// This is only here for backwards compat
|
||||
this.#platformSocket?.send("TASK_RUN_COMPLETED", {
|
||||
version: "v1",
|
||||
execution,
|
||||
completion,
|
||||
});
|
||||
} else {
|
||||
// 99.99% of runs should end up here
|
||||
|
||||
const completedWithAckBackoff = new ExponentialBackoff("FullJitter").maxRetries(
|
||||
TASK_RUN_COMPLETED_WITH_ACK_MAX_RETRIES
|
||||
);
|
||||
|
||||
const result = await completedWithAckBackoff.execute(
|
||||
async ({ retry, delay, elapsedMs }) => {
|
||||
logger.log("TASK_RUN_COMPLETED_WITH_ACK: sending with backoff", {
|
||||
retry,
|
||||
delay,
|
||||
elapsedMs,
|
||||
});
|
||||
|
||||
const success = await sendCompletionWithAck();
|
||||
|
||||
if (!success) {
|
||||
throw new Error("Failed to send completion with ack");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
logger.error("TASK_RUN_COMPLETED_WITH_ACK: failed to send with backoff", result);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("TASK_RUN_COMPLETED_WITH_ACK: sent with backoff", result);
|
||||
}
|
||||
};
|
||||
|
||||
if (completion.ok) {
|
||||
completeWithoutCheckpoint(true);
|
||||
await completeWithoutCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -736,17 +842,17 @@ class TaskCoordinator {
|
||||
completion.error.type === "INTERNAL_ERROR" &&
|
||||
completion.error.code === "TASK_RUN_CANCELLED"
|
||||
) {
|
||||
completeWithoutCheckpoint(true);
|
||||
await completeWithoutCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.retry === undefined) {
|
||||
completeWithoutCheckpoint(true);
|
||||
await completeWithoutCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.retry.delay < this.#delayThresholdInMs) {
|
||||
completeWithoutCheckpoint(false);
|
||||
await completeWithoutCheckpoint(false);
|
||||
|
||||
// Prevents runs that fail fast from never sending a heartbeat
|
||||
this.#sendRunHeartbeat(socket.data.runId);
|
||||
@@ -755,7 +861,7 @@ class TaskCoordinator {
|
||||
}
|
||||
|
||||
if (message.version === "v2") {
|
||||
completeWithoutCheckpoint(true);
|
||||
await completeWithoutCheckpoint(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -764,7 +870,7 @@ class TaskCoordinator {
|
||||
const willCheckpointAndRestore = canCheckpoint || willSimulate;
|
||||
|
||||
if (!willCheckpointAndRestore) {
|
||||
completeWithoutCheckpoint(false);
|
||||
await completeWithoutCheckpoint(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -788,7 +894,7 @@ class TaskCoordinator {
|
||||
|
||||
if (!checkpoint) {
|
||||
log.error("Failed to checkpoint");
|
||||
completeWithoutCheckpoint(false);
|
||||
await completeWithoutCheckpoint(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -832,7 +938,10 @@ class TaskCoordinator {
|
||||
|
||||
try {
|
||||
// Cancel all in-progress checkpoints (if any)
|
||||
this.#cancelCheckpoint(socket.data.runId);
|
||||
this.#cancelCheckpoint(socket.data.runId, {
|
||||
event: "TASK_RUN_FAILED_TO_RUN",
|
||||
errorType: completion.error.type,
|
||||
});
|
||||
|
||||
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
|
||||
version: "v1",
|
||||
@@ -885,12 +994,15 @@ class TaskCoordinator {
|
||||
|
||||
try {
|
||||
if (message.version === "v1") {
|
||||
this.#cancelCheckpoint(socket.data.runId);
|
||||
this.#cancelCheckpoint(socket.data.runId, { event: "CANCEL_CHECKPOINT", ...message });
|
||||
// v1 has no callback
|
||||
return;
|
||||
}
|
||||
|
||||
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
|
||||
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId, {
|
||||
event: "CANCEL_CHECKPOINT",
|
||||
...message,
|
||||
});
|
||||
|
||||
callback({ version: "v2", checkpointCanceled });
|
||||
} catch (error) {
|
||||
@@ -934,11 +1046,14 @@ class TaskCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = socket.data.runId;
|
||||
const attemptNumber = getAttemptNumber();
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush({
|
||||
runId: socket.data.runId,
|
||||
runId,
|
||||
projectRef: socket.data.projectRef,
|
||||
deploymentVersion: socket.data.deploymentVersion,
|
||||
attemptNumber: getAttemptNumber(),
|
||||
attemptNumber,
|
||||
});
|
||||
|
||||
if (!checkpoint) {
|
||||
@@ -964,6 +1079,13 @@ class TaskCoordinator {
|
||||
|
||||
if (ack?.keepRunAlive) {
|
||||
log.log("keeping run alive after duration checkpoint");
|
||||
|
||||
if (checkpoint.docker && willSimulate) {
|
||||
// The container is still paused so we need to unpause it
|
||||
log.log("unpausing container after duration checkpoint");
|
||||
this.#checkpointer.unpause(runId, attemptNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1022,12 +1144,18 @@ class TaskCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush({
|
||||
runId: socket.data.runId,
|
||||
projectRef: socket.data.projectRef,
|
||||
deploymentVersion: socket.data.deploymentVersion,
|
||||
attemptNumber: getAttemptNumber(),
|
||||
});
|
||||
const runId = socket.data.runId;
|
||||
const attemptNumber = getAttemptNumber();
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush(
|
||||
{
|
||||
runId,
|
||||
projectRef: socket.data.projectRef,
|
||||
deploymentVersion: socket.data.deploymentVersion,
|
||||
attemptNumber,
|
||||
},
|
||||
WAIT_FOR_TASK_CHECKPOINT_DELAY_MS
|
||||
);
|
||||
|
||||
if (!checkpoint) {
|
||||
log.error("Failed to checkpoint");
|
||||
@@ -1057,6 +1185,13 @@ class TaskCoordinator {
|
||||
if (ack?.keepRunAlive) {
|
||||
socket.data.requiresCheckpointResumeWithMessage = undefined;
|
||||
log.log("keeping run alive after task checkpoint");
|
||||
|
||||
if (checkpoint.docker && willSimulate) {
|
||||
// The container is still paused so we need to unpause it
|
||||
log.log("unpausing container after duration checkpoint");
|
||||
this.#checkpointer.unpause(runId, attemptNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1115,12 +1250,18 @@ class TaskCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush({
|
||||
runId: socket.data.runId,
|
||||
projectRef: socket.data.projectRef,
|
||||
deploymentVersion: socket.data.deploymentVersion,
|
||||
attemptNumber: getAttemptNumber(),
|
||||
});
|
||||
const runId = socket.data.runId;
|
||||
const attemptNumber = getAttemptNumber();
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush(
|
||||
{
|
||||
runId,
|
||||
projectRef: socket.data.projectRef,
|
||||
deploymentVersion: socket.data.deploymentVersion,
|
||||
attemptNumber,
|
||||
},
|
||||
WAIT_FOR_BATCH_CHECKPOINT_DELAY_MS
|
||||
);
|
||||
|
||||
if (!checkpoint) {
|
||||
log.error("Failed to checkpoint");
|
||||
@@ -1151,6 +1292,13 @@ class TaskCoordinator {
|
||||
if (ack?.keepRunAlive) {
|
||||
socket.data.requiresCheckpointResumeWithMessage = undefined;
|
||||
log.log("keeping run alive after batch checkpoint");
|
||||
|
||||
if (checkpoint.docker && willSimulate) {
|
||||
// The container is still paused so we need to unpause it
|
||||
log.log("unpausing container after batch checkpoint");
|
||||
this.#checkpointer.unpause(runId, attemptNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1338,7 +1486,9 @@ class TaskCoordinator {
|
||||
});
|
||||
}
|
||||
|
||||
#cancelCheckpoint(runId: string): boolean {
|
||||
#cancelCheckpoint(runId: string, reason?: any): boolean {
|
||||
logger.log("cancelCheckpoint: call", { runId, reason });
|
||||
|
||||
const checkpointWait = this.#checkpointableTasks.get(runId);
|
||||
|
||||
if (checkpointWait) {
|
||||
@@ -1347,9 +1497,14 @@ class TaskCoordinator {
|
||||
}
|
||||
|
||||
// Cancel checkpointing procedure
|
||||
const checkpointCanceled = this.#checkpointer.cancelCheckpoint(runId);
|
||||
const checkpointCanceled = this.#checkpointer.cancelAllCheckpointsForRun(runId);
|
||||
|
||||
logger.log("cancelCheckpoint()", { runId, checkpointCanceled });
|
||||
logger.log("cancelCheckpoint: result", {
|
||||
runId,
|
||||
reason,
|
||||
checkpointCanceled,
|
||||
hadCheckpointWait: !!checkpointWait,
|
||||
});
|
||||
|
||||
return checkpointCanceled;
|
||||
}
|
||||
@@ -1368,14 +1523,6 @@ class TaskCoordinator {
|
||||
case "/metrics": {
|
||||
return reply.text(await register.metrics(), 200, register.contentType);
|
||||
}
|
||||
case "/whoami": {
|
||||
return reply.text(NODE_NAME);
|
||||
}
|
||||
case "/checkpoint": {
|
||||
const body = await getTextBody(req);
|
||||
// await this.#checkpointer.checkpointAndPush(body);
|
||||
return reply.text(`sent restore request: ${body}`);
|
||||
}
|
||||
default: {
|
||||
return reply.empty(404);
|
||||
}
|
||||
@@ -1393,8 +1540,240 @@ class TaskCoordinator {
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
#createInternalHttpServer() {
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
logger.log(`[${req.method}]`, { url: req.url });
|
||||
|
||||
const reply = new HttpReply(res);
|
||||
|
||||
switch (req.url) {
|
||||
case "/whoami": {
|
||||
return reply.text(NODE_NAME);
|
||||
}
|
||||
case "/checkpoint/duration": {
|
||||
try {
|
||||
const body = await getTextBody(req);
|
||||
const json = safeJsonParse(body);
|
||||
|
||||
if (typeof json !== "object" || !json) {
|
||||
return reply.text("Invalid body", 400);
|
||||
}
|
||||
|
||||
if (!("runId" in json) || typeof json.runId !== "string") {
|
||||
return reply.text("Missing or invalid: runId", 400);
|
||||
}
|
||||
|
||||
if (!("now" in json) || typeof json.now !== "number") {
|
||||
return reply.text("Missing or invalid: now", 400);
|
||||
}
|
||||
|
||||
if (!("ms" in json) || typeof json.ms !== "number") {
|
||||
return reply.text("Missing or invalid: ms", 400);
|
||||
}
|
||||
|
||||
let keepRunAlive = false;
|
||||
if ("keepRunAlive" in json && typeof json.keepRunAlive === "boolean") {
|
||||
keepRunAlive = json.keepRunAlive;
|
||||
}
|
||||
|
||||
let async = false;
|
||||
if ("async" in json && typeof json.async === "boolean") {
|
||||
async = json.async;
|
||||
}
|
||||
|
||||
const { runId, now, ms } = json;
|
||||
|
||||
if (!runId) {
|
||||
return reply.text("Missing runId", 400);
|
||||
}
|
||||
|
||||
const runSocket = await this.#getRunSocket(runId);
|
||||
if (!runSocket) {
|
||||
return reply.text("Run socket not found", 404);
|
||||
}
|
||||
|
||||
const { data } = runSocket;
|
||||
|
||||
console.log("Manual duration checkpoint", data);
|
||||
|
||||
if (async) {
|
||||
reply.text("Creating checkpoint in the background", 202);
|
||||
}
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush({
|
||||
runId: data.runId,
|
||||
projectRef: data.projectRef,
|
||||
deploymentVersion: data.deploymentVersion,
|
||||
attemptNumber: data.attemptNumber ? parseInt(data.attemptNumber) : undefined,
|
||||
});
|
||||
|
||||
if (!checkpoint) {
|
||||
return reply.text("Failed to checkpoint", 500);
|
||||
}
|
||||
|
||||
if (!data.attemptFriendlyId) {
|
||||
return reply.text("Socket data missing attemptFriendlyId", 500);
|
||||
}
|
||||
|
||||
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
|
||||
version: "v1",
|
||||
runId,
|
||||
attemptFriendlyId: data.attemptFriendlyId,
|
||||
docker: checkpoint.docker,
|
||||
location: checkpoint.location,
|
||||
reason: {
|
||||
type: "WAIT_FOR_DURATION",
|
||||
ms,
|
||||
now,
|
||||
},
|
||||
});
|
||||
|
||||
if (ack?.keepRunAlive || keepRunAlive) {
|
||||
return reply.json({
|
||||
message: `keeping run ${runId} alive after checkpoint`,
|
||||
checkpoint,
|
||||
requestJson: json,
|
||||
platformAck: ack,
|
||||
});
|
||||
}
|
||||
|
||||
runSocket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
return reply.json({
|
||||
message: `checkpoint created for run ${runId}`,
|
||||
checkpoint,
|
||||
requestJson: json,
|
||||
platformAck: ack,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.json({
|
||||
message: `error`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
case "/checkpoint/manual": {
|
||||
try {
|
||||
const body = await getTextBody(req);
|
||||
const json = safeJsonParse(body);
|
||||
|
||||
if (typeof json !== "object" || !json) {
|
||||
return reply.text("Invalid body", 400);
|
||||
}
|
||||
|
||||
if (!("runId" in json) || typeof json.runId !== "string") {
|
||||
return reply.text("Missing or invalid: runId", 400);
|
||||
}
|
||||
|
||||
let restoreAtUnixTimeMs: number | undefined;
|
||||
if ("restoreAtUnixTimeMs" in json && typeof json.restoreAtUnixTimeMs === "number") {
|
||||
restoreAtUnixTimeMs = json.restoreAtUnixTimeMs;
|
||||
}
|
||||
|
||||
let keepRunAlive = false;
|
||||
if ("keepRunAlive" in json && typeof json.keepRunAlive === "boolean") {
|
||||
keepRunAlive = json.keepRunAlive;
|
||||
}
|
||||
|
||||
let async = false;
|
||||
if ("async" in json && typeof json.async === "boolean") {
|
||||
async = json.async;
|
||||
}
|
||||
|
||||
const { runId } = json;
|
||||
|
||||
if (!runId) {
|
||||
return reply.text("Missing runId", 400);
|
||||
}
|
||||
|
||||
const runSocket = await this.#getRunSocket(runId);
|
||||
if (!runSocket) {
|
||||
return reply.text("Run socket not found", 404);
|
||||
}
|
||||
|
||||
const { data } = runSocket;
|
||||
|
||||
console.log("Manual checkpoint", data);
|
||||
|
||||
if (async) {
|
||||
reply.text("Creating checkpoint in the background", 202);
|
||||
}
|
||||
|
||||
const checkpoint = await this.#checkpointer.checkpointAndPush({
|
||||
runId: data.runId,
|
||||
projectRef: data.projectRef,
|
||||
deploymentVersion: data.deploymentVersion,
|
||||
attemptNumber: data.attemptNumber ? parseInt(data.attemptNumber) : undefined,
|
||||
});
|
||||
|
||||
if (!checkpoint) {
|
||||
return reply.text("Failed to checkpoint", 500);
|
||||
}
|
||||
|
||||
if (!data.attemptFriendlyId) {
|
||||
return reply.text("Socket data missing attemptFriendlyId", 500);
|
||||
}
|
||||
|
||||
const ack = await this.#platformSocket?.sendWithAck("CHECKPOINT_CREATED", {
|
||||
version: "v1",
|
||||
runId,
|
||||
attemptFriendlyId: data.attemptFriendlyId,
|
||||
docker: checkpoint.docker,
|
||||
location: checkpoint.location,
|
||||
reason: {
|
||||
type: "MANUAL",
|
||||
restoreAtUnixTimeMs,
|
||||
},
|
||||
});
|
||||
|
||||
if (ack?.keepRunAlive || keepRunAlive) {
|
||||
return reply.json({
|
||||
message: `keeping run ${runId} alive after checkpoint`,
|
||||
checkpoint,
|
||||
requestJson: json,
|
||||
platformAck: ack,
|
||||
});
|
||||
}
|
||||
|
||||
runSocket.emit("REQUEST_EXIT", {
|
||||
version: "v1",
|
||||
});
|
||||
|
||||
return reply.json({
|
||||
message: `checkpoint created for run ${runId}`,
|
||||
checkpoint,
|
||||
requestJson: json,
|
||||
platformAck: ack,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.json({
|
||||
message: `error`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return reply.empty(404);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
httpServer.on("clientError", (err, socket) => {
|
||||
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
||||
});
|
||||
|
||||
httpServer.on("listening", () => {
|
||||
logger.log("internal server listening on port", { port: HTTP_SERVER_PORT + 100 });
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
}
|
||||
|
||||
listen() {
|
||||
this.#httpServer.listen(this.port, this.host);
|
||||
this.#internalHttpServer.listen(this.port + 100, "127.0.0.1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,15 @@ export const numFromEnv = (env: string, defaultValue: number): number => {
|
||||
|
||||
return parseInt(value, 10);
|
||||
};
|
||||
|
||||
export function safeJsonParse(json?: string): unknown {
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ class DockerTaskOperations implements TaskOperations {
|
||||
`--env=POD_NAME=${containerName}`,
|
||||
`--env=COORDINATOR_HOST=${COORDINATOR_HOST}`,
|
||||
`--env=COORDINATOR_PORT=${COORDINATOR_PORT}`,
|
||||
`--env=TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`,
|
||||
`--name=${containerName}`,
|
||||
];
|
||||
|
||||
@@ -129,6 +130,10 @@ class DockerTaskOperations implements TaskOperations {
|
||||
runArgs.push(`--cpus=${opts.machine.cpu}`, `--memory=${opts.machine.memory}G`);
|
||||
}
|
||||
|
||||
if (opts.dequeuedAt) {
|
||||
runArgs.push(`--env=TRIGGER_RUN_DEQUEUED_AT_MS=${opts.dequeuedAt}`);
|
||||
}
|
||||
|
||||
runArgs.push(`${opts.image}`);
|
||||
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { PodCleaner } from "./podCleaner";
|
||||
import { TaskMonitor } from "./taskMonitor";
|
||||
import { UptimeHeartbeat } from "./uptimeHeartbeat";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { CustomLabelHelper } from "./labelHelper";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "local";
|
||||
@@ -37,6 +39,15 @@ const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS |
|
||||
const POD_EPHEMERAL_STORAGE_SIZE_LIMIT = process.env.POD_EPHEMERAL_STORAGE_SIZE_LIMIT || "10Gi";
|
||||
const POD_EPHEMERAL_STORAGE_SIZE_REQUEST = process.env.POD_EPHEMERAL_STORAGE_SIZE_REQUEST || "2Gi";
|
||||
|
||||
// Image config
|
||||
const PRE_PULL_DISABLED = process.env.PRE_PULL_DISABLED === "true";
|
||||
const ADDITIONAL_PULL_SECRETS = process.env.ADDITIONAL_PULL_SECRETS;
|
||||
const PAUSE_IMAGE = process.env.PAUSE_IMAGE || "registry.k8s.io/pause:3.9";
|
||||
const BUSYBOX_IMAGE = process.env.BUSYBOX_IMAGE || "registry.digitalocean.com/trigger/busybox";
|
||||
const DEPLOYMENT_IMAGE_PREFIX = process.env.DEPLOYMENT_IMAGE_PREFIX;
|
||||
const RESTORE_IMAGE_PREFIX = process.env.RESTORE_IMAGE_PREFIX;
|
||||
const UTILITY_IMAGE_PREFIX = process.env.UTILITY_IMAGE_PREFIX;
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
logger.log(`running in ${RUNTIME_ENV} mode`);
|
||||
|
||||
@@ -63,6 +74,8 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
apps: k8s.AppsV1Api;
|
||||
};
|
||||
|
||||
#labelHelper = new CustomLabelHelper();
|
||||
|
||||
constructor(opts: { namespace?: string } = {}) {
|
||||
if (opts.namespace) {
|
||||
this.#namespace.metadata.name = opts.namespace;
|
||||
@@ -101,7 +114,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
containers: [
|
||||
{
|
||||
name: this.#getIndexContainerName(opts.shortCode),
|
||||
image: opts.imageRef,
|
||||
image: getImageRef("deployment", opts.imageRef),
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
@@ -155,6 +168,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
name: containerName,
|
||||
namespace: this.#namespace.metadata.name,
|
||||
labels: {
|
||||
...this.#labelHelper.getAdditionalLabels("create"),
|
||||
...this.#getSharedLabels(opts),
|
||||
app: "task-run",
|
||||
"app.kubernetes.io/part-of": "trigger-worker",
|
||||
@@ -168,7 +182,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
containers: [
|
||||
{
|
||||
name: containerName,
|
||||
image: opts.image,
|
||||
image: getImageRef("deployment", opts.image),
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
@@ -188,6 +202,9 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
name: "TRIGGER_RUN_ID",
|
||||
value: opts.runId,
|
||||
},
|
||||
...(opts.dequeuedAt
|
||||
? [{ name: "TRIGGER_RUN_DEQUEUED_AT_MS", value: String(opts.dequeuedAt) }]
|
||||
: []),
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
@@ -216,6 +233,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
name: `${this.#getRunContainerName(opts.runId)}-${opts.checkpointId.slice(-8)}`,
|
||||
namespace: this.#namespace.metadata.name,
|
||||
labels: {
|
||||
...this.#labelHelper.getAdditionalLabels("restore"),
|
||||
...this.#getSharedLabels(opts),
|
||||
app: "task-run",
|
||||
"app.kubernetes.io/part-of": "trigger-worker",
|
||||
@@ -229,12 +247,12 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
initContainers: [
|
||||
{
|
||||
name: "pull-base-image",
|
||||
image: opts.imageRef,
|
||||
image: getImageRef("deployment", opts.imageRef),
|
||||
command: ["sleep", "0"],
|
||||
},
|
||||
{
|
||||
name: "populate-taskinfo",
|
||||
image: "registry.digitalocean.com/trigger/busybox",
|
||||
image: getImageRef("utility", BUSYBOX_IMAGE),
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-c"],
|
||||
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
|
||||
@@ -250,7 +268,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
containers: [
|
||||
{
|
||||
name: this.#getRunContainerName(opts.runId),
|
||||
image: opts.checkpointRef,
|
||||
image: getImageRef("restore", opts.checkpointRef),
|
||||
ports: [
|
||||
{
|
||||
containerPort: 8000,
|
||||
@@ -301,6 +319,11 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
async prePullDeployment(opts: TaskOperationsPrePullDeploymentOptions) {
|
||||
if (PRE_PULL_DISABLED) {
|
||||
logger.debug("Pre-pull is disabled, skipping.", { opts });
|
||||
return;
|
||||
}
|
||||
|
||||
const metaName = this.#getPrePullContainerName(opts.shortCode);
|
||||
|
||||
const metaLabels = {
|
||||
@@ -332,10 +355,26 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
spec: {
|
||||
...this.#defaultPodSpec,
|
||||
restartPolicy: "Always",
|
||||
affinity: {
|
||||
nodeAffinity: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "trigger.dev/pre-pull-disabled",
|
||||
operator: "DoesNotExist",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
initContainers: [
|
||||
{
|
||||
name: "prepull",
|
||||
image: opts.imageRef,
|
||||
image: getImageRef("deployment", opts.imageRef),
|
||||
command: ["/usr/bin/true"],
|
||||
resources: {
|
||||
limits: {
|
||||
@@ -349,7 +388,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
containers: [
|
||||
{
|
||||
name: "pause",
|
||||
image: "registry.k8s.io/pause:3.9",
|
||||
image: getImageRef("utility", PAUSE_IMAGE),
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: "1m",
|
||||
@@ -380,17 +419,20 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
}
|
||||
|
||||
get #defaultPodSpec(): Omit<k8s.V1PodSpec, "containers"> {
|
||||
const pullSecrets = ["registry-trigger", "registry-trigger-failover"];
|
||||
|
||||
if (ADDITIONAL_PULL_SECRETS) {
|
||||
pullSecrets.push(...ADDITIONAL_PULL_SECRETS.split(","));
|
||||
}
|
||||
|
||||
const imagePullSecrets = pullSecrets.map(
|
||||
(name) => ({ name }) satisfies k8s.V1LocalObjectReference
|
||||
);
|
||||
|
||||
return {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
imagePullSecrets: [
|
||||
{
|
||||
name: "registry-trigger",
|
||||
},
|
||||
{
|
||||
name: "registry-trigger-failover",
|
||||
},
|
||||
],
|
||||
imagePullSecrets,
|
||||
nodeSelector: {
|
||||
nodetype: "worker",
|
||||
},
|
||||
@@ -478,6 +520,10 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TRIGGER_POD_SCHEDULED_AT_MS",
|
||||
value: Date.now().toString(),
|
||||
},
|
||||
...this.#coordinatorEnvVars,
|
||||
];
|
||||
}
|
||||
@@ -650,6 +696,26 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
}
|
||||
}
|
||||
|
||||
type ImageType = "deployment" | "restore" | "utility";
|
||||
|
||||
function getImagePrefix(type: ImageType) {
|
||||
switch (type) {
|
||||
case "deployment":
|
||||
return DEPLOYMENT_IMAGE_PREFIX;
|
||||
case "restore":
|
||||
return RESTORE_IMAGE_PREFIX;
|
||||
case "utility":
|
||||
return UTILITY_IMAGE_PREFIX;
|
||||
default:
|
||||
assertExhaustive(type);
|
||||
}
|
||||
}
|
||||
|
||||
function getImageRef(type: ImageType, ref: string) {
|
||||
const prefix = getImagePrefix(type);
|
||||
return prefix ? `${prefix}/${ref}` : ref;
|
||||
}
|
||||
|
||||
const provider = new ProviderShell({
|
||||
tasks: new KubernetesTaskOperations({
|
||||
namespace: KUBERNETES_NAMESPACE,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
|
||||
const CREATE_LABEL_ENV_VAR_PREFIX = "DEPLOYMENT_LABEL_";
|
||||
const RESTORE_LABEL_ENV_VAR_PREFIX = "RESTORE_LABEL_";
|
||||
const LABEL_SAMPLE_RATE_POSTFIX = "_SAMPLE_RATE";
|
||||
|
||||
type OperationType = "create" | "restore";
|
||||
|
||||
type CustomLabel = {
|
||||
key: string;
|
||||
value: string;
|
||||
sampleRate: number;
|
||||
};
|
||||
|
||||
export class CustomLabelHelper {
|
||||
// Labels and sample rates are defined in environment variables so only need to be computed once
|
||||
private createLabels?: CustomLabel[];
|
||||
private restoreLabels?: CustomLabel[];
|
||||
|
||||
private getLabelPrefix(type: OperationType) {
|
||||
const prefix = type === "create" ? CREATE_LABEL_ENV_VAR_PREFIX : RESTORE_LABEL_ENV_VAR_PREFIX;
|
||||
return prefix.toLowerCase();
|
||||
}
|
||||
|
||||
private getLabelSampleRatePostfix() {
|
||||
return LABEL_SAMPLE_RATE_POSTFIX.toLowerCase();
|
||||
}
|
||||
|
||||
// Can only range from 0 to 1
|
||||
private fractionFromPercent(percent: number) {
|
||||
return Math.min(1, Math.max(0, percent / 100));
|
||||
}
|
||||
|
||||
private isLabelSampleRateEnvVar(key: string) {
|
||||
return key.toLowerCase().endsWith(this.getLabelSampleRatePostfix());
|
||||
}
|
||||
|
||||
private isLabelEnvVar(type: OperationType, key: string) {
|
||||
const prefix = this.getLabelPrefix(type);
|
||||
return key.toLowerCase().startsWith(prefix) && !this.isLabelSampleRateEnvVar(key);
|
||||
}
|
||||
|
||||
private getSampleRateEnvVarKey(type: OperationType, envKey: string) {
|
||||
return `${envKey.toLowerCase()}${this.getLabelSampleRatePostfix()}`;
|
||||
}
|
||||
|
||||
private getLabelNameFromEnvVarKey(type: OperationType, key: string) {
|
||||
return key
|
||||
.slice(this.getLabelPrefix(type).length)
|
||||
.toLowerCase()
|
||||
.replace(/___/g, ".")
|
||||
.replace(/__/g, "/")
|
||||
.replace(/_/g, "-");
|
||||
}
|
||||
|
||||
private getCaseInsensitiveEnvValue(key: string) {
|
||||
for (const [envKey, value] of Object.entries(process.env)) {
|
||||
if (envKey.toLowerCase() === key.toLowerCase()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the sample rate for a given label as fraction of 100 */
|
||||
private getSampleRateFromEnvVarKey(type: OperationType, envKey: string) {
|
||||
// Apply default: always sample
|
||||
const DEFAULT_SAMPLE_RATE_PERCENT = 100;
|
||||
const defaultSampleRateFraction = this.fractionFromPercent(DEFAULT_SAMPLE_RATE_PERCENT);
|
||||
|
||||
const value = this.getCaseInsensitiveEnvValue(this.getSampleRateEnvVarKey(type, envKey));
|
||||
|
||||
if (!value) {
|
||||
return defaultSampleRateFraction;
|
||||
}
|
||||
|
||||
const sampleRatePercent = parseFloat(value || String(DEFAULT_SAMPLE_RATE_PERCENT));
|
||||
|
||||
if (isNaN(sampleRatePercent)) {
|
||||
return defaultSampleRateFraction;
|
||||
}
|
||||
|
||||
const fractionalSampleRate = this.fractionFromPercent(sampleRatePercent);
|
||||
|
||||
return fractionalSampleRate;
|
||||
}
|
||||
|
||||
private getCustomLabels(type: OperationType): CustomLabel[] {
|
||||
switch (type) {
|
||||
case "create":
|
||||
if (this.createLabels) {
|
||||
return this.createLabels;
|
||||
}
|
||||
break;
|
||||
case "restore":
|
||||
if (this.restoreLabels) {
|
||||
return this.restoreLabels;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
assertExhaustive(type);
|
||||
}
|
||||
|
||||
const customLabels: CustomLabel[] = [];
|
||||
|
||||
for (const [envKey, value] of Object.entries(process.env)) {
|
||||
const key = envKey.toLowerCase();
|
||||
|
||||
// Only process env vars that start with the expected prefix
|
||||
if (!this.isLabelEnvVar(type, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip sample rates - deal with them separately
|
||||
if (this.isLabelSampleRateEnvVar(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelName = this.getLabelNameFromEnvVarKey(type, key);
|
||||
const sampleRate = this.getSampleRateFromEnvVarKey(type, key);
|
||||
|
||||
const label = {
|
||||
key: labelName,
|
||||
value: value || "",
|
||||
sampleRate,
|
||||
} satisfies CustomLabel;
|
||||
|
||||
customLabels.push(label);
|
||||
}
|
||||
|
||||
return customLabels;
|
||||
}
|
||||
|
||||
getAdditionalLabels(type: OperationType): Record<string, string> {
|
||||
const labels = this.getCustomLabels(type);
|
||||
|
||||
const additionalLabels: Record<string, string> = {};
|
||||
|
||||
for (const { key, value, sampleRate } of labels) {
|
||||
// Always apply label if sample rate is 1
|
||||
if (sampleRate === 1) {
|
||||
additionalLabels[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.random() <= sampleRate) {
|
||||
additionalLabels[key] = value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return additionalLabels;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@trigger.dev/core": ["../../packages/core/src"],
|
||||
"@trigger.dev/core/*": ["../../packages/core/src/*"],
|
||||
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
|
||||
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"]
|
||||
}
|
||||
|
||||
@@ -16,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function KeyboardDownIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.8779 16.3905C12.4989 17.0854 11.5011 17.0854 11.1221 16.3905L6.80665 8.47885C6.44317 7.81248 6.92548 7 7.68454 7L16.3155 7C17.0745 7 17.5568 7.81248 17.1934 8.47885L12.8779 16.3905Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function KeyboardLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.60947 12.8779C6.91461 12.4989 6.91461 11.5011 7.60947 11.1221L15.5211 6.80665C16.1875 6.44317 17 6.92548 17 7.68454L17 16.3155C17 17.0745 16.1875 17.5568 15.5211 17.1934L7.60947 12.8779Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function KeyboardRightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.3905 11.1221C17.0854 11.5011 17.0854 12.4989 16.3905 12.8779L8.47885 17.1934C7.81248 17.5568 7 17.0745 7 16.3155L7 7.68454C7 6.92548 7.81248 6.44317 8.47885 6.80665L16.3905 11.1221Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function KeyboardUpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.1221 6.60947C11.5011 5.91461 12.4989 5.91461 12.8779 6.60947L17.1934 14.5211C17.5568 15.1875 17.0745 16 16.3155 16L7.68454 16C6.92548 16 6.44317 15.1875 6.80665 14.5211L11.1221 6.60947Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function KeyboardWindowsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="12"
|
||||
height="16"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="1.66666" y="3.66663" width="4" height="4" fill="currentColor" />
|
||||
<rect x="6.33334" y="3.66663" width="4" height="4" fill="currentColor" />
|
||||
<rect x="1.66666" y="8.33337" width="4" height="4" fill="currentColor" />
|
||||
<rect x="6.33334" y="8.33337" width="4" height="4" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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?: {
|
||||
@@ -48,25 +49,20 @@ export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
{message && <Paragraph>{message}</Paragraph>}
|
||||
<LinkButton
|
||||
to={button ? button.to : "/"}
|
||||
shortcut={{ modifiers: ["meta"], key: "g" }}
|
||||
shortcut={{ modifiers: ["mod"], key: "g" }}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={HomeIcon}
|
||||
>
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-4 right-4 z-10 h-[70px] w-[200px] bg-[rgb(24,26,30)]" />
|
||||
<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" }}
|
||||
>
|
||||
<iframe
|
||||
src="https://my.spline.design/untitled-a6f70b5ebc46bdb2dcc0f21d5397e8ac/"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
style={{ border: "none" }}
|
||||
/>
|
||||
<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,14 +26,16 @@ 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",
|
||||
!path &&
|
||||
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
|
||||
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
shortcut={{ key: "k" }}
|
||||
tooltip="Next"
|
||||
disabled={!path}
|
||||
>
|
||||
Next
|
||||
</LinkButton>
|
||||
@@ -47,14 +49,16 @@ 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",
|
||||
!path &&
|
||||
"cursor-not-allowed opacity-50 group-hover:bg-transparent group-hover:text-text-dimmed"
|
||||
!path && "cursor-not-allowed opacity-50 group-hover/button:bg-transparent"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
shortcut={{ key: "j" }}
|
||||
tooltip="Previous"
|
||||
disabled={!path}
|
||||
>
|
||||
Prev
|
||||
</LinkButton>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={Keyboard}
|
||||
leadingIconClassName="text-blue-500"
|
||||
data-action="shortcuts"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?" }}
|
||||
className="gap-x-0 pl-0.5"
|
||||
iconSpacing="gap-x-0.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Keyboard className="size-5 text-indigo-500" />
|
||||
<span className="font-sans text-base font-medium text-text-bright">
|
||||
Keyboard shortcuts
|
||||
</span>
|
||||
</div>
|
||||
</SheetTitle>
|
||||
<div className="space-y-6 px-4 pb-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
<Header3>General</Header3>
|
||||
<Shortcut name="Close">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Confirm">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Previous page">
|
||||
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Next page">
|
||||
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Help & Feedback">
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Runs page</Header3>
|
||||
<Shortcut name="Bulk action: Cancel runs">
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Bulk action: Replay runs">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Bulk action: Clear selection">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Run page</Header3>
|
||||
<Shortcut name="Replay run">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Overview">
|
||||
<ShortcutKey shortcut={{ key: "o" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Context">
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Metadata">
|
||||
<ShortcutKey shortcut={{ key: "m" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Navigate">
|
||||
<ShortcutKey shortcut={{ key: "arrowup" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowdown" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Collapse all">
|
||||
<ShortcutKey shortcut={{ key: "w" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle level">
|
||||
<ShortcutKey shortcut={{ key: "0" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Alerts page</Header3>
|
||||
<Shortcut name="New alert">
|
||||
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut({ children, name }: { children: React.ReactNode; name: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<span className="text-sm text-text-dimmed">{name}</span>
|
||||
<span className="flex items-center gap-x-0.5">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useIsImpersonating } from "~/hooks/useOrganizations";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { type loader } from "~/routes/resources.taskruns.$runParam.debug";
|
||||
import { UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { MarQSShortKeyProducer } from "~/v3/marqs/marqsKeyProducer";
|
||||
|
||||
export function AdminDebugRun({ friendlyId }: { friendlyId: string }) {
|
||||
const hasAdminAccess = useHasAdminAccess();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
if (!hasAdminAccess && !isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog key={`debug-${friendlyId}`}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="tertiary/small" LeadingIcon={Cog6ToothIcon}>
|
||||
Debug run
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DebugRunDialog friendlyId={friendlyId} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugRunDialog({ friendlyId }: { friendlyId: string }) {
|
||||
return (
|
||||
<DialogContent
|
||||
key={`debug`}
|
||||
className="overflow-y-auto sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[50vw]"
|
||||
>
|
||||
<DebugRunContent friendlyId={friendlyId} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugRunContent({ friendlyId }: { friendlyId: string }) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const isLoading = fetcher.state === "loading";
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(`/resources/taskruns/${friendlyId}/debug`);
|
||||
}, [friendlyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>Debugging run</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="grid place-items-center p-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : fetcher.data ? (
|
||||
<DebugRunData {...fetcher.data} />
|
||||
) : (
|
||||
<>Failed to get run debug data</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugRunData({
|
||||
run,
|
||||
queueConcurrencyLimit,
|
||||
queueCurrentConcurrency,
|
||||
envConcurrencyLimit,
|
||||
envCurrentConcurrency,
|
||||
queueReserveConcurrency,
|
||||
envReserveConcurrency,
|
||||
}: UseDataFunctionReturn<typeof loader>) {
|
||||
const keys = new MarQSShortKeyProducer("marqs:");
|
||||
|
||||
const withPrefix = (key: string) => `marqs:${key}`;
|
||||
|
||||
return (
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField value={run.id} variant="tertiary/small" iconButton />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Message key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.messageKey(run.id))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET message</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.messageKey(run.id))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueKey(run.runtimeEnvironment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue set</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`ZRANGE ${withPrefix(
|
||||
keys.queueKey(run.runtimeEnvironment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)} 0 -1`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue current concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueCurrentConcurrencyKey(
|
||||
run.runtimeEnvironment,
|
||||
run.queue,
|
||||
run.concurrencyKey ?? undefined
|
||||
)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(
|
||||
keys.queueCurrentConcurrencyKey(
|
||||
run.runtimeEnvironment,
|
||||
run.queue,
|
||||
run.concurrencyKey ?? undefined
|
||||
)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue reserve concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueReserveConcurrencyKeyFromQueue(
|
||||
keys.queueKey(run.runtimeEnvironment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(
|
||||
keys.queueReserveConcurrencyKeyFromQueue(
|
||||
keys.queueKey(run.runtimeEnvironment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueReserveConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue concurrency limit key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.queueConcurrencyLimitKey(run.runtimeEnvironment, run.queue))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET queue concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(
|
||||
keys.queueConcurrencyLimitKey(run.runtimeEnvironment, run.queue)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env current concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envCurrentConcurrencyKey(run.runtimeEnvironment))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get env current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(keys.envCurrentConcurrencyKey(run.runtimeEnvironment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env reserve concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envReserveConcurrencyKey(run.runtimeEnvironment.id))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get env reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(
|
||||
keys.envReserveConcurrencyKey(run.runtimeEnvironment.id)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envReserveConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env concurrency limit key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envConcurrencyLimitKey(run.runtimeEnvironment))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET env concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.envConcurrencyLimitKey(run.runtimeEnvironment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Shared queue key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.envSharedQueueKey(run.runtimeEnvironment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get shared queue set</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`ZRANGEBYSCORE ${withPrefix(
|
||||
keys.envSharedQueueKey(run.runtimeEnvironment)
|
||||
)} -inf ${Date.now()} WITHSCORES`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import { Highlight, Prism } from "prism-react-renderer";
|
||||
import { forwardRef, ReactNode, useCallback, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
|
||||
@@ -51,6 +54,9 @@ type CodeBlockProps = {
|
||||
|
||||
/** title text for the Title row */
|
||||
rowTitle?: string;
|
||||
|
||||
/** Whether to show the open in modal button */
|
||||
showOpenInModal?: boolean;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
@@ -178,6 +184,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
{
|
||||
showCopyButton = true,
|
||||
showLineNumbers = true,
|
||||
showOpenInModal = true,
|
||||
highlightedRanges,
|
||||
code,
|
||||
className,
|
||||
@@ -193,6 +200,9 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
) => {
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [modalCopied, setModalCopied] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -206,6 +216,19 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
[code]
|
||||
);
|
||||
|
||||
const onModalCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setModalCopied(true);
|
||||
setTimeout(() => {
|
||||
setModalCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[code]
|
||||
);
|
||||
|
||||
code = code.trim();
|
||||
const lineCount = code.split("\n").length;
|
||||
const maxLineWidth = lineCount.toString().length;
|
||||
@@ -222,147 +245,130 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
const shouldHighlight = lineCount <= 1000;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-md border border-grid-bright", className)}
|
||||
style={{
|
||||
backgroundColor: theme.plain.backgroundColor,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
translate="no"
|
||||
>
|
||||
{showChrome && <Chrome title={fileName} />}
|
||||
{rowTitle && <TitleRow title={rowTitle} />}
|
||||
{showCopyButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver}>
|
||||
<TooltipTrigger
|
||||
onClick={onCopied}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"absolute right-3 z-50 transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
showChrome ? "top-10" : "top-2.5",
|
||||
copied ? "text-emerald-500" : "text-charcoal-500 hover:text-charcoal-300"
|
||||
)}
|
||||
>
|
||||
{copied ? <ClipboardCheck className="size-4" /> : <Clipboard className="size-4" />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<>
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-md border border-grid-bright", className)}
|
||||
style={{
|
||||
backgroundColor: theme.plain.backgroundColor,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
translate="no"
|
||||
>
|
||||
{showChrome && <Chrome title={fileName} />}
|
||||
{rowTitle && <TitleRow title={rowTitle} />}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-2.5 z-50 flex gap-3",
|
||||
showChrome ? "right-1.5 top-1.5" : "top-2.5"
|
||||
)}
|
||||
>
|
||||
{showCopyButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={onCopied}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-4" />
|
||||
) : (
|
||||
<Clipboard className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showOpenInModal && (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger onClick={() => setIsModalOpen(true)}>
|
||||
<ArrowsPointingOutIcon className="size-4 transition-colors hover:text-text-bright" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Expand
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="px-2 py-3"
|
||||
preClassName="text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 pt-[2.9rem] sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[80vw]">
|
||||
<DialogHeader className="h-fit">
|
||||
<DialogTitle className="absolute left-3.5 top-2.5">
|
||||
{fileName && fileName}
|
||||
{rowTitle && rowTitle}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={onModalCopied}
|
||||
className="absolute right-4 top-16 z-50"
|
||||
LeadingIcon={modalCopied ? undefined : Clipboard}
|
||||
leadingIconClassName="size-3 -ml-1"
|
||||
>
|
||||
{modalCopied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="min-h-full"
|
||||
preClassName="text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 font-mono text-xs leading-relaxed",
|
||||
inheritedClassName
|
||||
)}
|
||||
style={inheritedStyle}
|
||||
dir="ltr"
|
||||
>
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (
|
||||
index === tokens.length - 1 &&
|
||||
line.length === 1 &&
|
||||
line[0].content === "\n"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line, key: index });
|
||||
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={
|
||||
"mr-2 flex-none select-none text-right text-charcoal-500 transition-opacity duration-500"
|
||||
}
|
||||
style={{
|
||||
width: `calc(8 * ${maxLineWidth / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-xs leading-relaxed" dir="ltr">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -394,3 +400,118 @@ export function TitleRow({ title }: { title: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HighlightCodeProps = {
|
||||
theme: PrismTheme;
|
||||
code: string;
|
||||
language: Language;
|
||||
showLineNumbers: boolean;
|
||||
highlightLines?: number[];
|
||||
maxLineWidth?: number;
|
||||
className?: string;
|
||||
preClassName?: string;
|
||||
};
|
||||
|
||||
function HighlightCode({
|
||||
theme,
|
||||
code,
|
||||
language,
|
||||
showLineNumbers,
|
||||
highlightLines,
|
||||
maxLineWidth,
|
||||
className,
|
||||
preClassName,
|
||||
}: HighlightCodeProps) {
|
||||
return (
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<div
|
||||
dir="ltr"
|
||||
className={cn(
|
||||
"overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 font-mono leading-relaxed",
|
||||
inheritedClassName,
|
||||
preClassName
|
||||
)}
|
||||
style={inheritedStyle}
|
||||
dir="ltr"
|
||||
>
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (index === tokens.length - 1 && line.length === 1 && line[0].content === "\n") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line, key: index });
|
||||
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={
|
||||
"mr-2 flex-none select-none text-right text-charcoal-500 transition-opacity duration-500"
|
||||
}
|
||||
style={{
|
||||
width: `calc(8 * ${(maxLineWidth as number) / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token, key });
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,183 @@
|
||||
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";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
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"
|
||||
/>
|
||||
<Shortcuts />
|
||||
</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,20 @@
|
||||
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";
|
||||
@@ -44,7 +37,6 @@ import {
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
projectEventsPath,
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
@@ -52,6 +44,7 @@ import {
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3ConcurrencyPath,
|
||||
v3DeploymentsPath,
|
||||
@@ -64,17 +57,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 +72,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">;
|
||||
@@ -224,29 +212,6 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<Paragraph variant="small/bright">
|
||||
This is a v2 project. V2 will be deprecated on January 31, 2025.{" "}
|
||||
<TextLink
|
||||
className="text-text-bright underline decoration-text-dimmed underline-offset-2 transition hover:text-text-bright hover:decoration-text-bright"
|
||||
to="https://trigger.dev/blog/v2-end-of-life-announcement"
|
||||
>
|
||||
Learn more
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="primary/medium"
|
||||
to="https://trigger.dev/docs/v3/upgrading-from-v2"
|
||||
fullWidth
|
||||
>
|
||||
Upgrade to v3
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
{isV3Project && isFreeV3User && (
|
||||
@@ -261,167 +226,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,
|
||||
@@ -593,12 +397,6 @@ function V2ProjectSideMenu({
|
||||
to={projectTriggersPath(organization, project)}
|
||||
data-action="triggers"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Events"
|
||||
icon={CursorArrowRaysIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
to={projectEventsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
@@ -631,8 +429,6 @@ function V3ProjectSideMenu({
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { alertsEnabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"} />
|
||||
@@ -649,6 +445,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 +488,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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const variants = {
|
||||
small:
|
||||
"grid place-items-center rounded-full px-[0.4rem] h-4 tracking-wider text-xxs bg-background-dimmed text-text-dimmed uppercase whitespace-nowrap",
|
||||
"extra-small":
|
||||
"grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 border-tracking-wider text-xxs bg-background-bright text-blue-500 whitespace-nowrap",
|
||||
"grid place-items-center border border-charcoal-650 rounded-sm px-1 h-4 tracking-wide text-xxs bg-background-bright text-blue-500 whitespace-nowrap",
|
||||
outline:
|
||||
"grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap",
|
||||
"outline-rounded":
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
|
||||
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import React, { forwardRef, ReactNode, useImperativeHandle, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { IconNamesOrString, NamedIcon } from "./NamedIcon";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
@@ -41,40 +42,52 @@ 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",
|
||||
textColor: "text-text-dimmed group-disabled/button:text-text-dimmed transition",
|
||||
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,9 @@ export type ButtonContentPropsType = {
|
||||
className?: string;
|
||||
shortcut?: ShortcutDefinition;
|
||||
variant: keyof typeof variant;
|
||||
shortcutPosition?: "before-trailing-icon" | "after-trailing-icon";
|
||||
tooltip?: ReactNode;
|
||||
iconSpacing?: string;
|
||||
};
|
||||
|
||||
export function ButtonContent(props: ButtonContentPropsType) {
|
||||
@@ -169,36 +190,52 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
fullWidth,
|
||||
textAlignLeft,
|
||||
className,
|
||||
tooltip,
|
||||
iconSpacing,
|
||||
} = props;
|
||||
const variation = allVariants.variant[props.variant];
|
||||
|
||||
// Based on the size prop, we'll use the corresponding variant classnames
|
||||
const btnClassName = cn(allVariants.$all, variation.button);
|
||||
const iconClassName = variation.icon;
|
||||
const iconSpacingClassName = variation.iconSpacing;
|
||||
const shortcutClassName = variation.shortcut;
|
||||
const textColorClassName = variation.textColor;
|
||||
|
||||
return (
|
||||
const renderShortcutKey = () =>
|
||||
shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttonContent = (
|
||||
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
|
||||
<div
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center",
|
||||
iconSpacingClassName
|
||||
iconSpacingClassName,
|
||||
iconSpacing
|
||||
)}
|
||||
>
|
||||
{LeadingIcon &&
|
||||
(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,32 +251,55 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
<>{text}</>
|
||||
))}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
props.shortcutPosition === "before-trailing-icon" &&
|
||||
renderShortcutKey()}
|
||||
|
||||
{TrailingIcon &&
|
||||
(typeof TrailingIcon === "string" ? (
|
||||
<NamedIcon
|
||||
name={TrailingIcon}
|
||||
className={cn(iconClassName, trailingIconClassName, "shrink-0 justify-end")}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end",
|
||||
variation.icon
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TrailingIcon
|
||||
className={cn(
|
||||
iconClassName,
|
||||
textColorClassName,
|
||||
variation.icon,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn(shortcutClassName)}
|
||||
shortcut={shortcut}
|
||||
variant={variation.shortcutVariant ?? "medium"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
|
||||
renderShortcutKey()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
|
||||
<TooltipContent className="text-dimmed flex items-center gap-3 py-1.5 pl-2.5 pr-3 text-xs">
|
||||
{tooltip} {shortcut && renderShortcutKey()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return buttonContent;
|
||||
}
|
||||
|
||||
type ButtonPropsType = Pick<
|
||||
@@ -267,7 +327,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 +373,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 +387,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 +402,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 +423,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;
|
||||
|
||||
@@ -7,6 +7,7 @@ type DateTimeProps = {
|
||||
includeSeconds?: boolean;
|
||||
includeTime?: boolean;
|
||||
showTimezone?: boolean;
|
||||
previousDate?: Date | string | null; // Add optional previous date for comparison
|
||||
};
|
||||
|
||||
export const DateTime = ({
|
||||
@@ -70,20 +71,116 @@ export function formatDateTime(
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = ({ date, timeZone = "UTC" }: DateTimeProps) => {
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC" }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
? new Date(previousDate)
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
const initialFormattedDateTime = formatDateTimeAccurate(realDate, timeZone, locales);
|
||||
// Initial formatted values
|
||||
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales);
|
||||
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales);
|
||||
|
||||
// State for the formatted time
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(
|
||||
realPrevDate && isSameDay(realDate, realPrevDate) ? initialTimeOnly : initialWithDate
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
const userTimeZone = resolvedOptions.timeZone;
|
||||
|
||||
// Check if we should show the date
|
||||
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
|
||||
|
||||
// Format with appropriate function
|
||||
setFormattedDateTime(
|
||||
showDatePart
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales)
|
||||
);
|
||||
}, [locales, realDate, realPrevDate]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
// Helper function to check if two dates are on the same day
|
||||
function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
// Format with date and time
|
||||
function formatSmartDateTime(date: Date, timeZone: string, locales: string[]): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Format time only
|
||||
function formatTimeOnly(date: Date, timeZone: string, locales: string[]): string {
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const DateTimeAccurate = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
previousDate = null,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
? new Date(previousDate)
|
||||
: previousDate
|
||||
: null;
|
||||
|
||||
// Use the new Smart formatting if previousDate is provided
|
||||
const initialFormattedDateTime = realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, timeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, timeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, timeZone, locales);
|
||||
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
const userTimeZone = resolvedOptions.timeZone;
|
||||
|
||||
setFormattedDateTime(formatDateTimeAccurate(realDate, resolvedOptions.timeZone, locales));
|
||||
}, [locales, realDate]);
|
||||
if (realPrevDate) {
|
||||
// Smart formatting based on whether date changed
|
||||
setFormattedDateTime(
|
||||
isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, userTimeZone, locales)
|
||||
: formatDateTimeAccurate(realDate, userTimeZone, locales)
|
||||
);
|
||||
} else {
|
||||
// Default behavior when no previous date
|
||||
setFormattedDateTime(formatDateTimeAccurate(realDate, userTimeZone, locales));
|
||||
}
|
||||
}, [locales, realDate, realPrevDate]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
@@ -96,7 +193,34 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[])
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore this works in 92.5% of browsers https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_datetimeformat_options_parameter_options_fractionalseconddigits_parameter
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, timeZone = "UTC" }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales);
|
||||
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
|
||||
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales));
|
||||
}, [locales, realDate]);
|
||||
|
||||
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
|
||||
};
|
||||
|
||||
function formatDateTimeShort(date: Date, timeZone: string, locales: string[]): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZone,
|
||||
// @ts-ignore fractionalSecondDigits works in most modern browsers
|
||||
fractionalSecondDigits: 3,
|
||||
}).format(date);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -94,7 +94,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-base font-normal leading-none tracking-tight text-text-bright", className)}
|
||||
className={cn("text-base font-normal text-text-bright", 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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from "~/utils/cn";
|
||||
import { Icon, RenderIcon } from "./Icon";
|
||||
|
||||
const containerBase =
|
||||
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-text-link has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-2 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
|
||||
"has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-charcoal-650 has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-1 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text";
|
||||
|
||||
const inputBase =
|
||||
"h-full w-full text-text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none";
|
||||
@@ -17,27 +17,27 @@ const variants = {
|
||||
container:
|
||||
"px-1 w-full h-10 rounded-[3px] border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
|
||||
input: "px-2 text-sm",
|
||||
iconSize: "h-4 w-4 ml-1",
|
||||
iconSize: "size-4 ml-1",
|
||||
shortcut: "mr-1 min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem] select-none",
|
||||
},
|
||||
medium: {
|
||||
container:
|
||||
"px-1 h-8 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
|
||||
input: "px-1.5 rounded text-sm",
|
||||
iconSize: "h-4 w-4 ml-0.5",
|
||||
iconSize: "size-4 ml-0.5",
|
||||
shortcut: "min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem]",
|
||||
},
|
||||
small: {
|
||||
container:
|
||||
"px-1 h-6 w-full rounded border border-charcoal-800 bg-charcoal-750 hover:border-charcoal-600 hover:bg-charcoal-650",
|
||||
input: "px-1 rounded text-xs",
|
||||
iconSize: "h-3 w-3 ml-0.5",
|
||||
iconSize: "size-3 ml-0.5",
|
||||
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
|
||||
},
|
||||
tertiary: {
|
||||
container: "px-1 h-6 w-full rounded hover:bg-charcoal-750",
|
||||
input: "px-1 rounded text-xs",
|
||||
iconSize: "h-3 w-3 ml-0.5",
|
||||
iconSize: "size-3 ml-0.5",
|
||||
shortcut: "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type Props = Parameters<typeof Link>[0] & {
|
||||
disabled?: boolean;
|
||||
disabledClassName?: string;
|
||||
};
|
||||
|
||||
export function LinkDisabled({ disabled = false, disabledClassName, ...props }: Props) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<span {...props} className={cn(props.className, disabledClassName)}>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <Link {...props}>{props.children}</Link>;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ButtonContent } from "./Buttons";
|
||||
import { LinkDisabled } from "./LinkWithDisabled";
|
||||
import { LinkButton } from "./Buttons";
|
||||
|
||||
export function PaginationControls({
|
||||
currentPage,
|
||||
@@ -20,17 +19,18 @@ export function PaginationControls({
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1" aria-label="Pagination">
|
||||
<LinkDisabled
|
||||
<nav className="flex items-center gap-0.5" aria-label="Pagination">
|
||||
<LinkButton
|
||||
to={pageUrl(location, currentPage - 1)}
|
||||
className={currentPage > 1 ? "group" : ""}
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
shortcut={{ key: "j" }}
|
||||
tooltip="Previous"
|
||||
disabled={currentPage === 1}
|
||||
disabledClassName="opacity-30 cursor-default"
|
||||
className={currentPage > 1 ? "group" : ""}
|
||||
>
|
||||
<ButtonContent variant="minimal/medium" LeadingIcon={ChevronLeftIcon}>
|
||||
Previous
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
Prev
|
||||
</LinkButton>
|
||||
|
||||
{showPageNumbers
|
||||
? calculatePageLinks(currentPage, totalPages).map((page, i) => (
|
||||
@@ -38,16 +38,17 @@ export function PaginationControls({
|
||||
))
|
||||
: null}
|
||||
|
||||
<LinkDisabled
|
||||
<LinkButton
|
||||
to={pageUrl(location, currentPage + 1)}
|
||||
className={currentPage !== totalPages ? "group" : ""}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
shortcut={{ key: "k" }}
|
||||
tooltip="Next"
|
||||
disabled={currentPage === totalPages}
|
||||
disabledClassName="opacity-30 cursor-default"
|
||||
className={currentPage !== totalPages ? "group" : ""}
|
||||
>
|
||||
<ButtonContent variant="minimal/medium" TrailingIcon={ChevronRightIcon}>
|
||||
Next
|
||||
</ButtonContent>
|
||||
</LinkDisabled>
|
||||
Next
|
||||
</LinkButton>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -61,9 +62,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 focus-visible:focus-custom";
|
||||
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
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header2 } from "./Headers";
|
||||
import { NamedIcon } from "./NamedIcon";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 overflow-y-auto bg-background-dimmed shadow-lg transition ease-in-out border-grid-dimmed data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = "SheetHeader";
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = "SheetFooter";
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"sticky top-0 flex items-center justify-between border-b border-grid-bright bg-background-dimmed pb-1.5 pl-3 pr-1.5 pt-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="flex items-center gap-1 rounded-sm p-1 pl-0 transition hover:bg-charcoal-750 focus-visible:focus-custom disabled:pointer-events-none">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
|
||||
<NamedIcon name="close" className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Title>
|
||||
));
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("overflow-y-auto px-3 py-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
@@ -1,25 +1,35 @@
|
||||
import { Fragment } from "react";
|
||||
import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys";
|
||||
import { KeyboardDownIcon } from "~/assets/icons/KeyboardDownIcon";
|
||||
import { KeyboardLeftIcon } from "~/assets/icons/KeyboardLeftIcon";
|
||||
import { KeyboardRightIcon } from "~/assets/icons/KeyboardRightIcon";
|
||||
import { KeyboardUpIcon } from "~/assets/icons/KeyboardUpIcon";
|
||||
import { KeyboardWindowsIcon } from "~/assets/icons/KeyboardWindowsIcon";
|
||||
import { Modifier, Shortcut } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
const medium =
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
|
||||
medium:
|
||||
"text-[0.75rem] font-medium min-w-[17px] rounded-[2px] px-1 ml-1 -mr-0.5 grid place-content-center border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
|
||||
"text-[0.6rem] font-medium min-w-[17px] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase",
|
||||
medium,
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
|
||||
export type ShortcutKeyVariant = keyof typeof variants;
|
||||
|
||||
type ShortcutKey = Partial<Shortcut>;
|
||||
|
||||
type ShortcutKeyDefinition =
|
||||
| {
|
||||
windows: ShortcutKey;
|
||||
mac: ShortcutKey;
|
||||
}
|
||||
| ShortcutKey;
|
||||
|
||||
type ShortcutKeyProps = {
|
||||
shortcut: ShortcutDefinition;
|
||||
shortcut: ShortcutKeyDefinition;
|
||||
variant: ShortcutKeyVariant;
|
||||
className?: string;
|
||||
};
|
||||
@@ -29,50 +39,62 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
const isMac = platform === "mac";
|
||||
let relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
const modifiers = relevantShortcut.modifiers ?? [];
|
||||
const character = keyString(relevantShortcut.key, isMac, variant);
|
||||
const character = relevantShortcut.key ? keyString(relevantShortcut.key, isMac, variant) : null;
|
||||
|
||||
return (
|
||||
<span className={cn(variants[variant], className)}>
|
||||
{modifiers.map((k) => (
|
||||
<Fragment key={k}>{modifierString(k, isMac)}</Fragment>
|
||||
<span key={k}>
|
||||
<span>{modifierString(k, isMac, variant)}</span>
|
||||
</span>
|
||||
))}
|
||||
{character}
|
||||
{character && <span>{character}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function keyString(key: String, isMac: boolean, size: "small" | "medium") {
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = size === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3 h-5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
return isMac ? "↵" : key;
|
||||
return isMac ? "↵" : <span className="capitalize">Enter</span>;
|
||||
case "esc":
|
||||
return <span className="capitalize">Esc</span>;
|
||||
case "del":
|
||||
return <span className="capitalize">Del</span>;
|
||||
case "arrowdown":
|
||||
return <ChevronDownIcon className={className} />;
|
||||
return <KeyboardDownIcon className={className} />;
|
||||
case "arrowup":
|
||||
return <ChevronUpIcon className={className} />;
|
||||
return <KeyboardUpIcon className={className} />;
|
||||
case "arrowleft":
|
||||
return <ChevronLeftIcon className={className} />;
|
||||
return <KeyboardLeftIcon className={className} />;
|
||||
case "arrowright":
|
||||
return <ChevronRightIcon className={className} />;
|
||||
return <KeyboardRightIcon className={className} />;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function modifierString(modifier: Modifier, isMac: boolean) {
|
||||
function modifierString(
|
||||
modifier: Modifier,
|
||||
isMac: boolean,
|
||||
variant: "small" | "medium" | "medium/bright"
|
||||
): string | JSX.Element {
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
|
||||
switch (modifier) {
|
||||
case "alt":
|
||||
return isMac ? "⌥" : "Alt+";
|
||||
return isMac ? "⌥" : <span className="capitalize">Alt</span>;
|
||||
case "ctrl":
|
||||
return isMac ? "⌃" : "Ctrl+";
|
||||
return isMac ? "⌃" : <span className="capitalize">Ctrl</span>;
|
||||
case "meta":
|
||||
return isMac ? "⌘" : "⊞+";
|
||||
return isMac ? "⌘" : <KeyboardWindowsIcon className={className} />;
|
||||
case "shift":
|
||||
return isMac ? "⇧" : "Shift+";
|
||||
return "⇧";
|
||||
case "mod":
|
||||
return isMac ? "⌘" : "Ctrl+";
|
||||
return isMac ? "⌘" : <span className="capitalize">Ctrl</span>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
width === "full" ? "w-full" : "w-min",
|
||||
sizeClassName,
|
||||
className
|
||||
|
||||
@@ -26,8 +26,8 @@ export function Spinner({
|
||||
foreground: "#3C4B62",
|
||||
},
|
||||
dark: {
|
||||
background: "#15171A",
|
||||
foreground: "#272A2E",
|
||||
background: "rgba(18, 19, 23, 0.35)",
|
||||
foreground: "#1A1B1F",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ const variations = {
|
||||
large: {
|
||||
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition focus-custom",
|
||||
root: "h-6 w-11",
|
||||
thumb: "h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-sm text-charcoal-400 group-hover:text-charcoal-200 transition",
|
||||
thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-sm text-text-dimmed",
|
||||
},
|
||||
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",
|
||||
thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
|
||||
text: "text-xs text-text-dimmed",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-text-link group-data-[state=unchecked]:bg-charcoal-700",
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
|
||||
root
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { ReactNode, forwardRef, useState } from "react";
|
||||
import React, { ReactNode, forwardRef, useState, useContext, createContext } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
|
||||
import { InfoIconTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
bright: {
|
||||
header: "bg-background-bright",
|
||||
cell: "group-hover/table-row:bg-charcoal-750 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
|
||||
stickyCell: "bg-background-bright group-hover/table-row:bg-charcoal-750",
|
||||
menuButton:
|
||||
"bg-background-bright group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/70 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750",
|
||||
menuButtonDivider: "group-hover/table-row:border-charcoal-600/70",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
dimmed: {
|
||||
header: "bg-background-dimmed",
|
||||
cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
stickyCell: "group-hover/table-row:bg-charcoal-800",
|
||||
menuButton:
|
||||
"bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
||||
menuButtonDivider: "group-hover/table-row:border-grid-dimmed",
|
||||
rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TableVariant = keyof typeof variants;
|
||||
|
||||
type TableProps = {
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
@@ -12,20 +35,25 @@ type TableProps = {
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, containerClassName, children, fullWidth }, ref) => {
|
||||
// Add TableContext
|
||||
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
|
||||
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap rounded-md border border-grid-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
>
|
||||
<table ref={ref} className={cn("w-full", className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
<TableContext.Provider value={{ variant }}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
>
|
||||
<table ref={ref} className={cn("w-full", className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</TableContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -37,11 +65,13 @@ type TableHeaderProps = {
|
||||
|
||||
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
|
||||
({ className, children }, ref) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
return (
|
||||
<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 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright",
|
||||
variants[variant].header,
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -59,10 +89,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 +100,22 @@ 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) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
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 outline-none after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
isSelected && variants[variant].rowSelected,
|
||||
disabled && "opacity-50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
@@ -88,7 +125,7 @@ export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
type TableCellBasicProps = {
|
||||
className?: string;
|
||||
alignment?: "left" | "center" | "right";
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
colSpan?: number;
|
||||
};
|
||||
|
||||
@@ -114,11 +151,12 @@ 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
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{hiddenLabel ? (
|
||||
<span className="sr-only">{children}</span>
|
||||
@@ -141,18 +179,12 @@ type TableCellProps = TableCellBasicProps & {
|
||||
hasAction?: boolean;
|
||||
isSticky?: boolean;
|
||||
actionClassName?: string;
|
||||
rowHoverStyle?: keyof typeof rowHoverStyles;
|
||||
rowHoverStyle?: string;
|
||||
isSelected?: boolean;
|
||||
isTabbableCell?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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]";
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
(
|
||||
{
|
||||
@@ -165,7 +197,8 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
onClick,
|
||||
hasAction = false,
|
||||
isSticky = false,
|
||||
rowHoverStyle = "default",
|
||||
isSelected,
|
||||
isTabbableCell = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -180,33 +213,47 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
}
|
||||
|
||||
const flexClasses = cn(
|
||||
"flex w-full whitespace-nowrap px-3 py-3 text-xs text-text-dimmed",
|
||||
"flex w-full whitespace-nowrap px-3 py-3 items-center text-xs text-text-dimmed",
|
||||
alignment === "left"
|
||||
? "justify-start text-left"
|
||||
: alignment === "center"
|
||||
? "justify-center text-center"
|
||||
: "justify-end text-right"
|
||||
);
|
||||
const { variant } = useContext(TableContext);
|
||||
|
||||
return (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xs text-charcoal-400 transition-colors",
|
||||
to || onClick || hasAction ? "cursor-pointer" : "px-3 py-3 align-middle",
|
||||
"text-xs text-charcoal-400 has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
|
||||
variants[variant].cell,
|
||||
to || onClick || hasAction ? "cursor-pointer" : "cursor-default px-3 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
rowHoverStyles[rowHoverStyle],
|
||||
isSticky &&
|
||||
"[&:has(.group-hover/table-row:block)]:w-auto sticky right-0 bg-background-dimmed",
|
||||
isSticky && variants[variant].stickyCell,
|
||||
isSelected && variants[variant].rowSelected,
|
||||
!isSelected &&
|
||||
"group-hover/table-row:before:absolute group-hover/table-row:before:left-0 group-hover/table-row:before:top-[-1px] group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:before:bg-charcoal-750 group-hover/table-row:after:absolute 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 group-hover/table-row:after:bg-charcoal-750 group-focus-visible/table-row:bg-background-bright",
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
>
|
||||
{to ? (
|
||||
<Link to={to} className={cn("focus-custom", flexClasses, actionClassName)}>
|
||||
<Link
|
||||
to={to}
|
||||
className={cn("cursor-pointer focus:outline-none", flexClasses, actionClassName)}
|
||||
tabIndex={isTabbableCell ? 0 : -1}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
) : onClick ? (
|
||||
<button onClick={onClick} className={cn("focus-custom", flexClasses, actionClassName)}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn("cursor-pointer focus:outline-none", flexClasses, actionClassName)}
|
||||
tabIndex={isTabbableCell ? 0 : -1}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
@@ -237,42 +284,103 @@ 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>
|
||||
);
|
||||
});
|
||||
|
||||
export const TableCellMenu = forwardRef<
|
||||
HTMLTableCellElement,
|
||||
{
|
||||
TableCellProps & {
|
||||
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);
|
||||
const { variant } = useContext(TableContext);
|
||||
|
||||
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] p-0.5 group-hover/table-row:ring-1",
|
||||
variants[variant].menuButton
|
||||
)}
|
||||
>
|
||||
{/* Hidden buttons that show on hover */}
|
||||
{hiddenButtons && (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden pr-0.5 group-hover/table-row:block group-hover/table-row:border-r",
|
||||
variants[variant].menuButtonDivider
|
||||
)}
|
||||
>
|
||||
{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;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Link, NavLink, useLocation } from "@remix-run/react";
|
||||
import { NavLink } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ReactNode, useRef } from "react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { projectPubSub } from "~/v3/services/projectPubSub.server";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
|
||||
export type TabsProps = {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
const variations = {
|
||||
primary:
|
||||
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group",
|
||||
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
secondary:
|
||||
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group",
|
||||
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
} as const;
|
||||
|
||||
type TextLinkProps = {
|
||||
@@ -34,14 +34,14 @@ export function TextLink({
|
||||
<Link to={to} className={cn(classes, className)} {...props}>
|
||||
{children}{" "}
|
||||
{trailingIcon && (
|
||||
<NamedIcon name={trailingIcon} className={cn("h-4 w-4", trailingIconClassName)} />
|
||||
<NamedIcon name={trailingIcon} className={cn("size-4", trailingIconClassName)} />
|
||||
)}
|
||||
</Link>
|
||||
) : href ? (
|
||||
<a href={href} className={cn(classes, className)} {...props}>
|
||||
{children}{" "}
|
||||
{trailingIcon && (
|
||||
<NamedIcon name={trailingIcon} className={cn("h-4 w-4", trailingIconClassName)} />
|
||||
<NamedIcon name={trailingIcon} className={cn("size-4", trailingIconClassName)} />
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
import { ClockIcon } from "@heroicons/react/20/solid";
|
||||
import type { SpanEvent } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
formatDuration,
|
||||
millisecondsToNanoseconds,
|
||||
nanosecondsToMilliseconds,
|
||||
} from "@trigger.dev/core/v3/utils/durations";
|
||||
import { Fragment, ReactNode, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { DateTime, DateTimeAccurate } from "../primitives/DateTime";
|
||||
import { LiveTimer } from "../runs/v3/LiveTimer";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
|
||||
// Types for the RunTimeline component
|
||||
export type TimelineEventState = "complete" | "error" | "inprogress" | "delayed";
|
||||
|
||||
type TimelineLineVariant = "light" | "normal";
|
||||
|
||||
type TimelineStyle = "normal" | "diminished";
|
||||
|
||||
type TimelineEventVariant =
|
||||
| "start-cap"
|
||||
| "dot-hollow"
|
||||
| "dot-solid"
|
||||
| "start-cap-thick"
|
||||
| "end-cap-thick"
|
||||
| "end-cap";
|
||||
|
||||
// Timeline item type definitions
|
||||
export type TimelineEventDefinition = {
|
||||
type: "event";
|
||||
id: string;
|
||||
title: string;
|
||||
date?: Date;
|
||||
previousDate: Date | undefined;
|
||||
state?: TimelineEventState;
|
||||
shouldRender: boolean;
|
||||
variant: TimelineEventVariant;
|
||||
helpText?: string;
|
||||
};
|
||||
|
||||
export type TimelineLineDefinition = {
|
||||
type: "line";
|
||||
id: string;
|
||||
title: React.ReactNode;
|
||||
state?: TimelineEventState;
|
||||
shouldRender: boolean;
|
||||
variant: TimelineLineVariant;
|
||||
};
|
||||
|
||||
export type TimelineItem = TimelineEventDefinition | TimelineLineDefinition;
|
||||
|
||||
/**
|
||||
* TimelineSpanRun represents the minimal set of run properties needed
|
||||
* to render the RunTimeline component.
|
||||
*/
|
||||
export type TimelineSpanRun = {
|
||||
// Core timestamps
|
||||
createdAt: Date; // When the run was created/triggered
|
||||
startedAt?: Date | null; // When the run was dequeued
|
||||
executedAt?: Date | null; // When the run actually started executing
|
||||
updatedAt: Date; // Last update timestamp (used for finish time)
|
||||
expiredAt?: Date | null; // When the run expired (if applicable)
|
||||
completedAt?: Date | null; // When the run completed
|
||||
|
||||
// Delay information
|
||||
delayUntil?: Date | null; // If the run is delayed, when it will be processed
|
||||
ttl?: string | null; // Time-to-live value if applicable
|
||||
|
||||
// Status flags
|
||||
isFinished: boolean; // Whether the run has completed
|
||||
isError: boolean; // Whether the run ended with an error
|
||||
};
|
||||
|
||||
export function RunTimeline({ run }: { run: TimelineSpanRun }) {
|
||||
// Build timeline items based on the run state
|
||||
const timelineItems = buildTimelineItems(run);
|
||||
|
||||
// Filter out items that shouldn't be rendered
|
||||
const visibleItems = timelineItems.filter((item) => item.shouldRender);
|
||||
|
||||
return (
|
||||
<div className="min-w-fit max-w-80">
|
||||
{visibleItems.map((item) => {
|
||||
if (item.type === "event") {
|
||||
return (
|
||||
<RunTimelineEvent
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
subtitle={
|
||||
item.date ? (
|
||||
<DateTimeAccurate date={item.date} previousDate={item.previousDate} />
|
||||
) : null
|
||||
}
|
||||
state={item.state as "complete" | "error"}
|
||||
variant={item.variant}
|
||||
helpText={item.helpText}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RunTimelineLine
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
state={item.state}
|
||||
variant={item.variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Centralized function to build all timeline items
|
||||
function buildTimelineItems(run: TimelineSpanRun): TimelineItem[] {
|
||||
let state: TimelineEventState;
|
||||
if (run.isError) {
|
||||
state = "error";
|
||||
} else if (run.expiredAt) {
|
||||
state = "error";
|
||||
} else if (run.isFinished) {
|
||||
state = "complete";
|
||||
} else {
|
||||
state = "inprogress";
|
||||
}
|
||||
|
||||
const items: TimelineItem[] = [];
|
||||
|
||||
// 1. Triggered Event
|
||||
items.push({
|
||||
type: "event",
|
||||
id: "triggered",
|
||||
title: "Triggered",
|
||||
date: run.createdAt,
|
||||
previousDate: undefined,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "start-cap",
|
||||
helpText: getHelpTextForEvent("Triggered"),
|
||||
});
|
||||
|
||||
// 2. Waiting to dequeue line
|
||||
if (run.delayUntil && !run.startedAt && !run.expiredAt) {
|
||||
// Delayed, not yet started
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "waiting-to-dequeue",
|
||||
title: (
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="size-4" />
|
||||
<span>
|
||||
Delayed until <DateTime date={run.delayUntil} /> {run.ttl && <>(TTL {run.ttl})</>}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
} else if (run.startedAt) {
|
||||
// Already dequeued - show the waiting duration
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "waiting-to-dequeue",
|
||||
title: formatDuration(run.createdAt, run.startedAt),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
} else if (run.expiredAt) {
|
||||
// Expired before dequeuing
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "waiting-to-dequeue",
|
||||
title: formatDuration(run.createdAt, run.expiredAt),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
} else {
|
||||
// Still waiting to be dequeued
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "waiting-to-dequeue",
|
||||
title: (
|
||||
<>
|
||||
<LiveTimer
|
||||
startTime={run.createdAt}
|
||||
endTime={run.startedAt ?? run.expiredAt ?? undefined}
|
||||
/>{" "}
|
||||
{run.ttl && <>(TTL {run.ttl})</>}
|
||||
</>
|
||||
),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Dequeued Event (if applicable)
|
||||
if (run.startedAt) {
|
||||
items.push({
|
||||
type: "event",
|
||||
id: "dequeued",
|
||||
title: "Dequeued",
|
||||
date: run.startedAt,
|
||||
previousDate: run.createdAt,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "dot-hollow",
|
||||
helpText: getHelpTextForEvent("Dequeued"),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Handle the case based on whether executedAt exists
|
||||
if (run.startedAt && !run.expiredAt) {
|
||||
if (run.executedAt) {
|
||||
// New behavior: Run has executedAt timestamp
|
||||
|
||||
// 4a. Show waiting to execute line
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "waiting-to-execute",
|
||||
title: formatDuration(run.startedAt, run.executedAt),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
|
||||
// 4b. Show Started event
|
||||
items.push({
|
||||
type: "event",
|
||||
id: "started",
|
||||
title: "Started",
|
||||
date: run.executedAt,
|
||||
previousDate: run.startedAt,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "start-cap-thick",
|
||||
helpText: getHelpTextForEvent("Started"),
|
||||
});
|
||||
|
||||
// 4c. Show executing line if applicable
|
||||
if (run.isFinished) {
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "executing",
|
||||
title: formatDuration(run.executedAt, run.updatedAt),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "normal",
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "executing",
|
||||
title: <LiveTimer startTime={run.executedAt} />,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "normal",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Legacy behavior: Run doesn't have executedAt timestamp
|
||||
|
||||
// If the run is finished, show a line directly from Dequeued to Finished
|
||||
if (run.isFinished) {
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "legacy-executing",
|
||||
title: formatDuration(run.startedAt, run.updatedAt),
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "normal",
|
||||
});
|
||||
} else {
|
||||
// Still waiting to start or execute (can't distinguish without executedAt)
|
||||
items.push({
|
||||
type: "line",
|
||||
id: "legacy-waiting-or-executing",
|
||||
title: <LiveTimer startTime={run.startedAt} />,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "light",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Finished Event (if applicable)
|
||||
if (run.isFinished && !run.expiredAt) {
|
||||
items.push({
|
||||
type: "event",
|
||||
id: "finished",
|
||||
title: "Finished",
|
||||
date: run.updatedAt,
|
||||
previousDate: run.executedAt ?? run.startedAt ?? undefined,
|
||||
state,
|
||||
shouldRender: true,
|
||||
variant: "end-cap-thick",
|
||||
helpText: getHelpTextForEvent("Finished"),
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Expired Event (if applicable)
|
||||
if (run.expiredAt) {
|
||||
items.push({
|
||||
type: "event",
|
||||
id: "expired",
|
||||
title: "Expired",
|
||||
date: run.expiredAt,
|
||||
previousDate: run.createdAt,
|
||||
state: "error",
|
||||
shouldRender: true,
|
||||
variant: "dot-solid",
|
||||
helpText: getHelpTextForEvent("Expired"),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export type RunTimelineEventProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
state?: "complete" | "error" | "inprogress";
|
||||
variant?: TimelineEventVariant;
|
||||
helpText?: string;
|
||||
style?: TimelineStyle;
|
||||
};
|
||||
|
||||
export function RunTimelineEvent({
|
||||
title,
|
||||
subtitle,
|
||||
state,
|
||||
variant = "dot-hollow",
|
||||
helpText,
|
||||
style = "normal",
|
||||
}: RunTimelineEventProps) {
|
||||
return (
|
||||
<div className="grid h-5 grid-cols-[1.125rem_1fr] gap-1 text-sm">
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<EventMarker variant={variant} state={state} style={style} />
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-default">
|
||||
<span className="font-medium text-text-bright">{title}</span>
|
||||
</TooltipTrigger>
|
||||
{helpText && (
|
||||
<TooltipContent className="flex items-center gap-1 text-xs">
|
||||
{helpText}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{subtitle ? (
|
||||
<span className="text-xs tabular-nums text-text-dimmed">{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventMarker({
|
||||
variant,
|
||||
state,
|
||||
style,
|
||||
}: {
|
||||
variant: TimelineEventVariant;
|
||||
state?: TimelineEventState;
|
||||
style?: TimelineStyle;
|
||||
}) {
|
||||
let bgClass = "bg-text-dimmed";
|
||||
switch (state) {
|
||||
case "complete":
|
||||
bgClass = "bg-success";
|
||||
break;
|
||||
case "error":
|
||||
bgClass = "bg-error";
|
||||
break;
|
||||
case "delayed":
|
||||
bgClass = "bg-text-dimmed";
|
||||
break;
|
||||
case "inprogress":
|
||||
bgClass = style === "normal" ? "bg-pending" : "bg-text-dimmed";
|
||||
break;
|
||||
}
|
||||
|
||||
let borderClass = "border-text-dimmed";
|
||||
switch (state) {
|
||||
case "complete":
|
||||
borderClass = "border-success";
|
||||
break;
|
||||
case "error":
|
||||
borderClass = "border-error";
|
||||
break;
|
||||
case "delayed":
|
||||
borderClass = "border-text-dimmed";
|
||||
break;
|
||||
case "inprogress":
|
||||
borderClass = style === "normal" ? "border-pending" : "border-text-dimmed";
|
||||
break;
|
||||
default:
|
||||
borderClass = "border-text-dimmed";
|
||||
break;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case "start-cap":
|
||||
return (
|
||||
<>
|
||||
<div className={cn("h-full w-[0.4375rem] border-b", borderClass)} />
|
||||
<div className={cn("relative h-full w-px", bgClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "dot-hollow":
|
||||
return (
|
||||
<>
|
||||
<div className={cn("relative h-full w-px", bgClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn("size-[0.3125rem] min-h-[0.3125rem] rounded-full border", borderClass)}
|
||||
/>
|
||||
<div className={cn("relative h-full w-px", bgClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "dot-solid":
|
||||
return <div className={cn("size-[0.3125rem] rounded-full", bgClass)} />;
|
||||
case "start-cap-thick":
|
||||
return (
|
||||
<div className={cn("relative h-full w-[0.4375rem] rounded-t-[0.125rem]", bgClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll-offset opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "end-cap-thick":
|
||||
return <div className={cn("h-full w-[0.4375rem] rounded-b-[0.125rem]", bgClass)} />;
|
||||
default:
|
||||
return <div className={cn("size-[0.3125rem] rounded-full bg-yellow-500")} />;
|
||||
}
|
||||
}
|
||||
|
||||
export type RunTimelineLineProps = {
|
||||
title: ReactNode;
|
||||
state?: TimelineEventState;
|
||||
variant?: TimelineLineVariant;
|
||||
style?: TimelineStyle;
|
||||
};
|
||||
|
||||
export function RunTimelineLine({
|
||||
title,
|
||||
state,
|
||||
variant = "normal",
|
||||
style = "normal",
|
||||
}: RunTimelineLineProps) {
|
||||
return (
|
||||
<div className="grid h-6 grid-cols-[1.125rem_1fr] gap-1 text-xs">
|
||||
<div className="flex items-stretch justify-center">
|
||||
<LineMarker state={state} variant={variant} style={style} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-text-dimmed">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LineMarker({
|
||||
state,
|
||||
variant,
|
||||
style,
|
||||
}: {
|
||||
state?: TimelineEventState;
|
||||
variant: TimelineLineVariant;
|
||||
style?: TimelineStyle;
|
||||
}) {
|
||||
let containerClass = "bg-text-dimmed";
|
||||
switch (state) {
|
||||
case "complete":
|
||||
containerClass = "bg-success";
|
||||
break;
|
||||
case "error":
|
||||
containerClass = "bg-error";
|
||||
break;
|
||||
case "delayed":
|
||||
containerClass = "bg-text-dimmed";
|
||||
break;
|
||||
case "inprogress":
|
||||
containerClass =
|
||||
style === "normal"
|
||||
? "rounded-b-[0.125rem] bg-pending"
|
||||
: "rounded-b-[0.125rem] bg-text-dimmed";
|
||||
break;
|
||||
}
|
||||
|
||||
switch (variant) {
|
||||
case "normal":
|
||||
return (
|
||||
<div className={cn("relative w-[0.4375rem]", containerClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "light":
|
||||
return (
|
||||
<div className={cn("relative w-px", containerClass)}>
|
||||
{state === "inprogress" && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full animate-tile-scroll opacity-50"
|
||||
style={{
|
||||
backgroundImage: `url(${tileBgPath})`,
|
||||
backgroundSize: "8px 8px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="w-px rounded-[0.125rem] bg-text-dimmed" />;
|
||||
}
|
||||
}
|
||||
|
||||
export type SpanTimelineProps = {
|
||||
startTime: Date;
|
||||
duration: number;
|
||||
inProgress: boolean;
|
||||
isError: boolean;
|
||||
events?: TimelineSpanEvent[];
|
||||
style?: TimelineStyle;
|
||||
};
|
||||
|
||||
export type SpanTimelineState = "error" | "pending" | "complete";
|
||||
|
||||
export function SpanTimeline({
|
||||
startTime,
|
||||
duration,
|
||||
inProgress,
|
||||
isError,
|
||||
events,
|
||||
style = "diminished",
|
||||
}: SpanTimelineProps) {
|
||||
const state = isError ? "error" : inProgress ? "inprogress" : undefined;
|
||||
|
||||
const visibleEvents = events ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-fit max-w-80">
|
||||
{visibleEvents.map((event, index) => {
|
||||
// Store previous date to compare
|
||||
const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp;
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<RunTimelineEvent
|
||||
title={event.name}
|
||||
subtitle={<DateTimeAccurate date={event.timestamp} previousDate={prevDate} />}
|
||||
variant={event.markerVariant}
|
||||
state={state}
|
||||
helpText={event.helpText}
|
||||
style={style}
|
||||
/>
|
||||
<RunTimelineLine
|
||||
title={
|
||||
index === visibleEvents.length - 1
|
||||
? // Last event - calculate duration until span start time
|
||||
formatDuration(event.timestamp, startTime)
|
||||
: // Calculate duration until next event
|
||||
formatDuration(event.timestamp, visibleEvents[index + 1].timestamp)
|
||||
}
|
||||
variant={event.lineVariant}
|
||||
state={state}
|
||||
style={style}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<RunTimelineEvent
|
||||
title="Started"
|
||||
subtitle={
|
||||
<DateTimeAccurate
|
||||
date={startTime}
|
||||
previousDate={
|
||||
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null
|
||||
}
|
||||
/>
|
||||
}
|
||||
variant={"start-cap-thick"}
|
||||
state={state}
|
||||
helpText={getHelpTextForEvent("Started")}
|
||||
style={style}
|
||||
/>
|
||||
{state === "inprogress" ? (
|
||||
<RunTimelineLine
|
||||
title={<LiveTimer startTime={startTime} />}
|
||||
state={state}
|
||||
variant="normal"
|
||||
style={style}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RunTimelineLine
|
||||
title={formatDuration(
|
||||
startTime,
|
||||
new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))
|
||||
)}
|
||||
state={isError ? "error" : undefined}
|
||||
variant="normal"
|
||||
style={style}
|
||||
/>
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={
|
||||
<DateTimeAccurate
|
||||
date={new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))}
|
||||
previousDate={startTime}
|
||||
/>
|
||||
}
|
||||
state={isError ? "error" : undefined}
|
||||
variant="end-cap-thick"
|
||||
helpText={getHelpTextForEvent("Finished")}
|
||||
style={style}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type TimelineSpanEvent = {
|
||||
name: string;
|
||||
offset: number;
|
||||
timestamp: Date;
|
||||
duration?: number;
|
||||
helpText?: string;
|
||||
markerVariant: TimelineEventVariant;
|
||||
lineVariant: TimelineLineVariant;
|
||||
};
|
||||
|
||||
export function createTimelineSpanEventsFromSpanEvents(
|
||||
spanEvents: SpanEvent[],
|
||||
isAdmin: boolean,
|
||||
relativeStartTime?: number
|
||||
): Array<TimelineSpanEvent> {
|
||||
// Rest of function remains the same
|
||||
if (!spanEvents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchingSpanEvents = spanEvents.filter((spanEvent) =>
|
||||
spanEvent.name.startsWith("trigger.dev/")
|
||||
);
|
||||
|
||||
if (matchingSpanEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedSpanEvents = [...matchingSpanEvents].sort((a, b) => {
|
||||
if (a.time === b.time) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
const aTime = typeof a.time === "string" ? new Date(a.time) : a.time;
|
||||
const bTime = typeof b.time === "string" ? new Date(b.time) : b.time;
|
||||
|
||||
return aTime.getTime() - bTime.getTime();
|
||||
});
|
||||
|
||||
const visibleSpanEvents = sortedSpanEvents.filter(
|
||||
(spanEvent) =>
|
||||
isAdmin ||
|
||||
!getAdminOnlyForEvent(
|
||||
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
|
||||
? spanEvent.properties.event
|
||||
: spanEvent.name
|
||||
)
|
||||
);
|
||||
|
||||
if (visibleSpanEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstEventTime =
|
||||
typeof visibleSpanEvents[0].time === "string"
|
||||
? new Date(visibleSpanEvents[0].time)
|
||||
: visibleSpanEvents[0].time;
|
||||
|
||||
const $relativeStartTime = relativeStartTime ?? firstEventTime.getTime();
|
||||
|
||||
const events = visibleSpanEvents.map((spanEvent, index) => {
|
||||
const timestamp =
|
||||
typeof spanEvent.time === "string" ? new Date(spanEvent.time) : spanEvent.time;
|
||||
|
||||
const offset = millisecondsToNanoseconds(timestamp.getTime() - $relativeStartTime);
|
||||
|
||||
const duration =
|
||||
"duration" in spanEvent.properties && typeof spanEvent.properties.duration === "number"
|
||||
? spanEvent.properties.duration
|
||||
: undefined;
|
||||
|
||||
const name =
|
||||
"event" in spanEvent.properties && typeof spanEvent.properties.event === "string"
|
||||
? spanEvent.properties.event
|
||||
: spanEvent.name;
|
||||
|
||||
let markerVariant: TimelineEventVariant = "dot-hollow";
|
||||
|
||||
if (index === 0) {
|
||||
markerVariant = "start-cap";
|
||||
}
|
||||
|
||||
return {
|
||||
name: getFriendlyNameForEvent(name),
|
||||
offset,
|
||||
timestamp,
|
||||
duration,
|
||||
properties: spanEvent.properties,
|
||||
helpText: getHelpTextForEvent(name),
|
||||
markerVariant,
|
||||
lineVariant: "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
// Now sort by offset, ascending
|
||||
events.sort((a, b) => a.offset - b.offset);
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function getFriendlyNameForEvent(event: string): string {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return "Dequeued";
|
||||
}
|
||||
case "fork": {
|
||||
return "Launched";
|
||||
}
|
||||
case "create_attempt": {
|
||||
return "Attempt created";
|
||||
}
|
||||
case "import": {
|
||||
return "Imported task file";
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return "Lazy attempt initialized";
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return "Pod scheduled";
|
||||
}
|
||||
default: {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAdminOnlyForEvent(event: string): boolean {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return false;
|
||||
}
|
||||
case "fork": {
|
||||
return false;
|
||||
}
|
||||
case "create_attempt": {
|
||||
return true;
|
||||
}
|
||||
case "import": {
|
||||
return true;
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return true;
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHelpTextForEvent(event: string): string | undefined {
|
||||
switch (event) {
|
||||
case "dequeue": {
|
||||
return "The run was dequeued from the queue";
|
||||
}
|
||||
case "fork": {
|
||||
return "The process was created to run the task";
|
||||
}
|
||||
case "create_attempt": {
|
||||
return "An attempt was created for the run";
|
||||
}
|
||||
case "import": {
|
||||
return "A task file was imported";
|
||||
}
|
||||
case "lazy_payload": {
|
||||
return "The payload was initialized lazily";
|
||||
}
|
||||
case "pod_scheduled": {
|
||||
return "The Kubernetes pod was scheduled to run";
|
||||
}
|
||||
case "Triggered": {
|
||||
return "The run was triggered";
|
||||
}
|
||||
case "Dequeued": {
|
||||
return "The run was dequeued from the queue";
|
||||
}
|
||||
case "Started": {
|
||||
return "The run began executing";
|
||||
}
|
||||
case "Finished": {
|
||||
return "The run completed execution";
|
||||
}
|
||||
case "Expired": {
|
||||
return "The run expired before it could be started";
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ["mod"],
|
||||
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,88 @@
|
||||
import { CheckCircleIcon, XCircleIcon } 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", "ABORTED"] 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.",
|
||||
ABORTED: "The batch was aborted because some child tasks could not be triggered.",
|
||||
};
|
||||
|
||||
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)} />;
|
||||
case "ABORTED":
|
||||
return <XCircleIcon 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";
|
||||
case "ABORTED":
|
||||
return "text-error";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function batchStatusTitle(status: BatchTaskRunStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "In progress";
|
||||
case "COMPLETED":
|
||||
return "Completed";
|
||||
case "ABORTED":
|
||||
return "Aborted";
|
||||
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: ["mod"], 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: ["mod"], 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type RunTimelineItemProps = {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
state: "complete" | "error";
|
||||
};
|
||||
|
||||
export function RunTimelineEvent({ title, subtitle, state }: RunTimelineItemProps) {
|
||||
return (
|
||||
<div className="grid h-5 grid-cols-[1.125rem_1fr] text-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[0.3125rem] rounded-full",
|
||||
state === "complete" ? "bg-success" : "bg-error"
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-medium text-text-bright">{title}</span>
|
||||
{subtitle ? <span className="text-xs text-text-dimmed">{subtitle}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RunTimelineLineProps = {
|
||||
title: ReactNode;
|
||||
state: "complete" | "delayed" | "inprogress";
|
||||
};
|
||||
|
||||
export function RunTimelineLine({ title, state }: RunTimelineLineProps) {
|
||||
return (
|
||||
<div className="grid h-6 grid-cols-[1.125rem_1fr] text-xs">
|
||||
<div className="flex items-stretch justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-px",
|
||||
state === "complete" ? "bg-success" : state === "delayed" ? "bg-text-dimmed" : ""
|
||||
)}
|
||||
style={
|
||||
state === "inprogress"
|
||||
? {
|
||||
width: "1px",
|
||||
height: "100%",
|
||||
background:
|
||||
"repeating-linear-gradient(to bottom, #3B82F6 0%, #3B82F6 50%, transparent 50%, transparent 100%)",
|
||||
backgroundSize: "1px 6px",
|
||||
maskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||
WebkitMaskImage: "linear-gradient(to bottom, black 50%, transparent 100%)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-text-dimmed">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +152,14 @@ 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 }}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter", enabledOnInputElements: true }}
|
||||
>
|
||||
{isSubmitting ? "Replaying..." : "Replay run"}
|
||||
</Button>
|
||||
|
||||
@@ -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,10 +45,10 @@ 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" }}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Retrying..." : "Retry indexing"}
|
||||
</Button>
|
||||
|
||||
@@ -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 {
|
||||
@@ -26,13 +27,16 @@ export function RollbackDeploymentDialog({
|
||||
|
||||
return (
|
||||
<DialogContent key="rollback">
|
||||
<DialogHeader>Roll back to this deployment?</DialogHeader>
|
||||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs. Tasks triggered but not
|
||||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||||
with these tasks included.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||||
method="post"
|
||||
@@ -41,12 +45,54 @@ 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" }}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Rolling back..." : "Roll back deployment"}
|
||||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromoteDeploymentDialog({
|
||||
projectId,
|
||||
deploymentShortCode,
|
||||
redirectPath,
|
||||
}: RollbackDeploymentDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="promote">
|
||||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||||
<DialogDescription>
|
||||
This deployment will become the default for all future runs not explicitly tied to a
|
||||
specific deployment.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Form
|
||||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||||
method="post"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||||
</Button>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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,
|
||||
@@ -522,9 +444,11 @@ function TasksDropdown({
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
key={`${item.triggerSource}-${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: ["mod"],
|
||||
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: ["mod"],
|
||||
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: ["mod"],
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,673 +0,0 @@
|
||||
import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import {
|
||||
formatDuration,
|
||||
formatDurationMilliseconds,
|
||||
TaskRunError,
|
||||
taskRunErrorEnhancer,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { CodeBlock, TitleRow } from "~/components/code/CodeBlock";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { RawRun } from "~/hooks/useSyncTraceRuns";
|
||||
import { loader } from "~/routes/resources.runs.$runParam";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import {
|
||||
v3RunDownloadLogsPath,
|
||||
v3RunPath,
|
||||
v3RunSpanPath,
|
||||
v3RunsPath,
|
||||
v3SchedulePath,
|
||||
v3TraceSpanPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { TraceSpan } from "~/utils/taskEvent";
|
||||
import { SpanLink } from "~/v3/eventRepository.server";
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
|
||||
import { RunTag } from "./RunTag";
|
||||
import { TaskRunStatusCombo } from "./TaskRunStatus";
|
||||
|
||||
/**
|
||||
* The RunInspector displays live information about a run.
|
||||
* Most of that data comes in as params but for some we need to fetch it.
|
||||
*/
|
||||
export function RunInspector({
|
||||
run,
|
||||
span,
|
||||
runParam,
|
||||
closePanel,
|
||||
}: {
|
||||
run?: RawRun;
|
||||
span?: TraceSpan;
|
||||
runParam: string;
|
||||
closePanel?: () => void;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
|
||||
useEffect(() => {
|
||||
if (run?.friendlyId === undefined) return;
|
||||
fetcher.load(`/resources/runs/${run.friendlyId}`);
|
||||
}, [run?.friendlyId, run?.updatedAt]);
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon name={"task"} spanName="" className="h-4 min-h-4 w-4 min-w-4" />
|
||||
<Header2 className={cn("overflow-x-hidden text-blue-500")}>
|
||||
<span className="truncate"></span>
|
||||
</Header2>
|
||||
</div>
|
||||
{closePanel && (
|
||||
<Button
|
||||
onClick={closePanel}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const environment = project.environments.find((e) => e.id === run.runtimeEnvironmentId);
|
||||
const clientRunData = fetcher.state === "idle" ? fetcher.data : undefined;
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon
|
||||
name={"task"}
|
||||
spanName={run.taskIdentifier}
|
||||
className="h-4 min-h-4 w-4 min-w-4"
|
||||
/>
|
||||
<Header2 className={cn("overflow-x-hidden text-blue-500")}>
|
||||
<span className="truncate">{run.taskIdentifier}</span>
|
||||
</Header2>
|
||||
</div>
|
||||
{closePanel && (
|
||||
<Button
|
||||
onClick={closePanel}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
layoutId="span-run"
|
||||
onClick={() => {
|
||||
replace({ tab: "overview" });
|
||||
}}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "detail"}
|
||||
layoutId="span-run"
|
||||
onClick={() => {
|
||||
replace({ tab: "detail" });
|
||||
}}
|
||||
shortcut={{ key: "d" }}
|
||||
>
|
||||
Detail
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "context"}
|
||||
layoutId="span-run"
|
||||
onClick={() => {
|
||||
replace({ tab: "context" });
|
||||
}}
|
||||
shortcut={{ key: "c" }}
|
||||
>
|
||||
Context
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div>
|
||||
{tab === "detail" ? (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
{run ? <TaskRunStatusCombo status={run.status} /> : <PropertyLoading />}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3RunsPath(organization, project, {
|
||||
tasks: [run.taskIdentifier],
|
||||
})}
|
||||
>
|
||||
{run.taskIdentifier}
|
||||
</TextLink>
|
||||
}
|
||||
content={`Filter runs by ${run.taskIdentifier}`}
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{clientRunData ? (
|
||||
clientRunData?.version ? (
|
||||
clientRunData.version
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Never started</span>
|
||||
<InfoIconTooltip
|
||||
content={"Runs get locked to the latest version when they start."}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<PropertyLoading />
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>SDK version</Property.Label>
|
||||
<Property.Value>
|
||||
{clientRunData ? (
|
||||
clientRunData?.sdkVersion ? (
|
||||
clientRunData.sdkVersion
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Never started</span>
|
||||
<InfoIconTooltip
|
||||
content={"Runs get locked to the latest version when they start."}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<PropertyLoading />
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Test run</Property.Label>
|
||||
<Property.Value>
|
||||
{run.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{environment && (
|
||||
<Property.Item>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Schedule</Property.Label>
|
||||
<Property.Value>
|
||||
{clientRunData ? (
|
||||
clientRunData.schedule ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono">
|
||||
{clientRunData.schedule.generatorExpression}
|
||||
</span>
|
||||
<span>({clientRunData.schedule.timezone})</span>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3SchedulePath(organization, project, clientRunData.schedule)}
|
||||
>
|
||||
{clientRunData.schedule.description}
|
||||
</TextLink>
|
||||
}
|
||||
content={`Go to schedule ${clientRunData.schedule.friendlyId}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
"No schedule"
|
||||
)
|
||||
) : (
|
||||
<PropertyLoading />
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue</Property.Label>
|
||||
<Property.Value>
|
||||
{clientRunData ? (
|
||||
<>
|
||||
<div>Name: {clientRunData.queue.name}</div>
|
||||
<div>
|
||||
Concurrency key:{" "}
|
||||
{clientRunData.queue.concurrencyKey
|
||||
? clientRunData.queue.concurrencyKey
|
||||
: "–"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<PropertyLoading />
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Time to live (TTL)</Property.Label>
|
||||
<Property.Value>{run.ttl ?? "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Tags</Property.Label>
|
||||
<Property.Value>
|
||||
{clientRunData ? (
|
||||
clientRunData.tags.length === 0 ? (
|
||||
"–"
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
|
||||
{clientRunData.tags.map((tag) => (
|
||||
<SimpleTooltip
|
||||
key={tag}
|
||||
button={
|
||||
<Link to={v3RunsPath(organization, project, { tags: [tag] })}>
|
||||
<RunTag tag={tag} />
|
||||
</Link>
|
||||
}
|
||||
content={`Filter runs by ${tag}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<PropertyLoading />
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{span?.links && span.links.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Links</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="space-y-1">
|
||||
{span.links.map((link, index) => (
|
||||
<SpanLinkElement key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Max duration</Property.Label>
|
||||
<Property.Value>
|
||||
{run.maxDurationInSeconds ? `${run.maxDurationInSeconds}s` : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Run invocation cost</Property.Label>
|
||||
<Property.Value>
|
||||
{run.baseCostInCents > 0
|
||||
? formatCurrencyAccurate(run.baseCostInCents / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Compute cost</Property.Label>
|
||||
<Property.Value>
|
||||
{run.costInCents > 0 ? formatCurrencyAccurate(run.costInCents / 100) : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Total cost</Property.Label>
|
||||
<Property.Value>
|
||||
{run.costInCents > 0
|
||||
? formatCurrencyAccurate((run.baseCostInCents + run.costInCents) / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Usage duration</Property.Label>
|
||||
<Property.Value>
|
||||
{run.usageDurationMs > 0
|
||||
? formatDurationMilliseconds(run.usageDurationMs, { style: "short" })
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>{run.id}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
) : tab === "context" ? (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{clientRunData ? (
|
||||
<CodeBlock code={clientRunData.context} showLineNumbers={false} />
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-md border border-grid-bright">
|
||||
<TitleRow
|
||||
title={
|
||||
<span className="flex items-center justify-between gap-2 text-sm text-text-bright">
|
||||
Context loading…
|
||||
<PropertyLoading />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<div className="border-b border-grid-bright pb-3">
|
||||
<TaskRunStatusCombo status={run.status} className="text-sm" />
|
||||
</div>
|
||||
<RunTimeline run={run} />
|
||||
<>
|
||||
{clientRunData ? (
|
||||
<>
|
||||
{clientRunData.payload !== undefined && (
|
||||
<PacketDisplay
|
||||
data={clientRunData.payload}
|
||||
dataType={clientRunData.payloadType}
|
||||
title="Payload"
|
||||
/>
|
||||
)}
|
||||
{clientRunData.error !== undefined ? (
|
||||
<RunError error={clientRunData.error} />
|
||||
) : clientRunData.output !== undefined ? (
|
||||
<PacketDisplay
|
||||
data={clientRunData.output}
|
||||
dataType={clientRunData.outputType}
|
||||
title="Output"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-md border border-grid-bright">
|
||||
<TitleRow
|
||||
title={
|
||||
<span className="flex items-center justify-between gap-2 text-sm text-text-bright">
|
||||
Payload loading…
|
||||
<PropertyLoading />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{run.friendlyId !== runParam && (
|
||||
<LinkButton
|
||||
to={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
{ friendlyId: run.friendlyId },
|
||||
{ spanId: run.spanId }
|
||||
)}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={QueueListIcon}
|
||||
shortcut={{ key: "f" }}
|
||||
>
|
||||
Focus on run
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{run.logsDeletedAt === null ? (
|
||||
<LinkButton
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
LeadingIcon={CloudArrowDownIcon}
|
||||
variant="tertiary/medium"
|
||||
target="_blank"
|
||||
download
|
||||
>
|
||||
Download logs
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyLoading() {
|
||||
return <Spinner className="mt-1 size-4" color="muted" />;
|
||||
}
|
||||
|
||||
function RunTimeline({ run }: { run: RawRun }) {
|
||||
const createdAt = new Date(run.createdAt);
|
||||
const startedAt = run.startedAt ? new Date(run.startedAt) : null;
|
||||
const delayUntil = run.delayUntil ? new Date(run.delayUntil) : null;
|
||||
const expiredAt = run.expiredAt ? new Date(run.expiredAt) : null;
|
||||
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">
|
||||
<RunTimelineEvent
|
||||
title="Triggered"
|
||||
subtitle={<DateTimeAccurate date={run.createdAt} />}
|
||||
state="complete"
|
||||
/>
|
||||
{delayUntil && !expiredAt ? (
|
||||
<RunTimelineLine
|
||||
title={
|
||||
run.startedAt ? (
|
||||
<>{formatDuration(createdAt, delayUntil)} delay</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="size-4" />
|
||||
<span>
|
||||
Delayed until <DateTime date={delayUntil} /> {run.ttl && <>(TTL {run.ttl})</>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
state={run.startedAt ? "complete" : "delayed"}
|
||||
/>
|
||||
) : startedAt ? (
|
||||
<RunTimelineLine title={formatDuration(createdAt, startedAt)} state={"complete"} />
|
||||
) : (
|
||||
<RunTimelineLine
|
||||
title={
|
||||
<>
|
||||
<LiveTimer startTime={createdAt} endTime={startedAt ?? expiredAt ?? undefined} />{" "}
|
||||
{run.ttl && <>(TTL {run.ttl})</>}
|
||||
</>
|
||||
}
|
||||
state={run.startedAt || run.expiredAt ? "complete" : "inprogress"}
|
||||
/>
|
||||
)}
|
||||
{expiredAt ? (
|
||||
<RunTimelineEvent
|
||||
title="Expired"
|
||||
subtitle={<DateTimeAccurate date={expiredAt} />}
|
||||
state="error"
|
||||
/>
|
||||
) : startedAt ? (
|
||||
<>
|
||||
<RunTimelineEvent
|
||||
title="Started"
|
||||
subtitle={<DateTimeAccurate date={startedAt} />}
|
||||
state="complete"
|
||||
/>
|
||||
{isFinished ? (
|
||||
<>
|
||||
<RunTimelineLine title={formatDuration(startedAt, updatedAt)} state={"complete"} />
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={<DateTimeAccurate date={updatedAt} />}
|
||||
state={isError ? "error" : "complete"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<RunTimelineLine
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<Spinner className="size-4" />
|
||||
<span>
|
||||
<LiveTimer startTime={startedAt} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
state={"inprogress"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunError({ error }: { error: TaskRunError }) {
|
||||
const enhancedError = taskRunErrorEnhancer(error);
|
||||
|
||||
switch (enhancedError.type) {
|
||||
case "STRING_ERROR":
|
||||
case "CUSTOM_ERROR": {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={enhancedError.raw}
|
||||
maxLines={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "BUILT_IN_ERROR":
|
||||
case "INTERNAL_ERROR": {
|
||||
const name = "name" in enhancedError ? enhancedError.name : enhancedError.code;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
|
||||
<Header3 className="text-rose-500">{name}</Header3>
|
||||
{enhancedError.message && <Callout variant="error">{enhancedError.message}</Callout>}
|
||||
{enhancedError.link && (
|
||||
<Callout variant="docs" to={enhancedError.link.href}>
|
||||
{enhancedError.link.name}
|
||||
</Callout>
|
||||
)}
|
||||
{enhancedError.stackTrace && (
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={enhancedError.stackTrace}
|
||||
maxLines={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PacketDisplay({
|
||||
data,
|
||||
dataType,
|
||||
title,
|
||||
}: {
|
||||
data: string;
|
||||
dataType: string;
|
||||
title: string;
|
||||
}) {
|
||||
switch (dataType) {
|
||||
case "application/store": {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Paragraph variant="base/bright" className="w-full border-b border-grid-dimmed py-2.5">
|
||||
{title}
|
||||
</Paragraph>
|
||||
<LinkButton LeadingIcon={CloudArrowDownIcon} to={data} variant="tertiary/medium" download>
|
||||
Download
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "text/plain": {
|
||||
return (
|
||||
<CodeBlock
|
||||
language="markdown"
|
||||
rowTitle={title}
|
||||
code={data}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<CodeBlock
|
||||
language="json"
|
||||
rowTitle={title}
|
||||
code={data}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SpanLinkElement({ link }: { link: SpanLink }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
switch (link.type) {
|
||||
case "run": {
|
||||
return (
|
||||
<TextLink to={v3RunPath(organization, project, { friendlyId: link.runId })}>
|
||||
{link.title}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
case "span": {
|
||||
return (
|
||||
<TextLink to={v3TraceSpanPath(organization, project, link.traceId, link.spanId)}>
|
||||
{link.title}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function RunTag({ tag }: { tag: string }) {
|
||||
<span className="flex items-center border-y border-r border-charcoal-700 bg-charcoal-800 pr-1.5 text-text-dimmed">
|
||||
{tagResult.key}
|
||||
</span>
|
||||
<span className="flex items-center rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
|
||||
<span className="flex items-center whitespace-nowrap rounded-r-sm border-y border-r border-charcoal-700 bg-charcoal-750 px-1.5 text-text-dimmed">
|
||||
{tagResult.value}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex w-full">
|
||||
<Input
|
||||
name="search"
|
||||
placeholder="Search schedule id, external id, deduplication id or CRON pattern"
|
||||
@@ -103,7 +103,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
defaultValue={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
<SelectGroup>
|
||||
<SelectGroup className="ml-2">
|
||||
<Select name="type" value={type ?? "ALL"} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder={"Select type"} className="ml-2 whitespace-nowrap p-0" />
|
||||
|
||||
@@ -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: ["mod"],
|
||||
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);
|
||||
}
|
||||
@@ -18,9 +18,15 @@ type SpanEventsProps = {
|
||||
};
|
||||
|
||||
export function SpanEvents({ spanEvents }: SpanEventsProps) {
|
||||
const displayableEvents = spanEvents.filter((event) => !event.name.startsWith("trigger.dev/"));
|
||||
|
||||
if (displayableEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{spanEvents.map((event, index) => (
|
||||
{displayableEvents.map((event, index) => (
|
||||
<SpanEvent key={index} spanEvent={event} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
|
||||
import { SpanTitle } from "~/components/runs/v3/SpanTitle";
|
||||
import { TaskRunAttemptStatusCombo } from "~/components/runs/v3/TaskRunAttemptStatus";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3RunPath, v3RunsPath, v3TraceSpanPath } from "~/utils/pathBuilder";
|
||||
import { TraceSpan } from "~/utils/taskEvent";
|
||||
import { SpanLink } from "~/v3/eventRepository.server";
|
||||
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { LiveTimer } from "./LiveTimer";
|
||||
import { formatDuration, nanosecondsToMilliseconds } from "@trigger.dev/core/v3";
|
||||
|
||||
export function SpanInspector({
|
||||
span,
|
||||
runParam,
|
||||
closePanel,
|
||||
}: {
|
||||
span?: TraceSpan;
|
||||
runParam?: string;
|
||||
closePanel?: () => void;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const { value, replace } = useSearchParams();
|
||||
let tab = value("tab");
|
||||
|
||||
if (tab === "context") {
|
||||
tab = "overview";
|
||||
}
|
||||
|
||||
if (span === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon
|
||||
name={span.style?.icon}
|
||||
spanName={span.message}
|
||||
className="h-4 min-h-4 w-4 min-w-4"
|
||||
/>
|
||||
<Header2 className={cn("overflow-x-hidden")}>
|
||||
<SpanTitle {...span} size="large" />
|
||||
</Header2>
|
||||
</div>
|
||||
{runParam && closePanel && (
|
||||
<Button
|
||||
onClick={closePanel}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
layoutId="span-span"
|
||||
onClick={() => {
|
||||
replace({ tab: "overview" });
|
||||
}}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "detail"}
|
||||
layoutId="span-span"
|
||||
onClick={() => {
|
||||
replace({ tab: "detail" });
|
||||
}}
|
||||
shortcut={{ key: "d" }}
|
||||
>
|
||||
Detail
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div>
|
||||
{tab === "detail" ? (
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<TaskRunAttemptStatusCombo
|
||||
status={
|
||||
span.isCancelled
|
||||
? "CANCELED"
|
||||
: span.isError
|
||||
? "FAILED"
|
||||
: span.isPartial
|
||||
? "EXECUTING"
|
||||
: "COMPLETED"
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3RunsPath(organization, project, { tasks: [span.taskSlug] })}
|
||||
>
|
||||
{span.taskSlug}
|
||||
</TextLink>
|
||||
}
|
||||
content={`Filter runs by ${span.taskSlug}`}
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{span.idempotencyKey && (
|
||||
<Property.Item>
|
||||
<Property.Label>Idempotency key</Property.Label>
|
||||
<Property.Value>{span.idempotencyKey}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{span.workerVersion ? (
|
||||
span.workerVersion
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Never started</span>
|
||||
<InfoIconTooltip
|
||||
content={"Runs get locked to the latest version when they start."}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{span.links && span.links.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Links</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="space-y-1">
|
||||
{span.links.map((link, index) => (
|
||||
<SpanLinkElement key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
{span.level === "TRACE" ? (
|
||||
<>
|
||||
<div className="border-b border-grid-bright pb-3">
|
||||
<TaskRunAttemptStatusCombo
|
||||
status={
|
||||
span.isCancelled
|
||||
? "CANCELED"
|
||||
: span.isError
|
||||
? "FAILED"
|
||||
: span.isPartial
|
||||
? "EXECUTING"
|
||||
: "COMPLETED"
|
||||
}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<SpanTimeline
|
||||
startTime={new Date(span.startTime)}
|
||||
duration={span.duration}
|
||||
inProgress={span.isPartial}
|
||||
isError={span.isError}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="min-w-fit max-w-80">
|
||||
<RunTimelineEvent
|
||||
title="Timestamp"
|
||||
subtitle={<DateTimeAccurate date={span.startTime} />}
|
||||
state="complete"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Message</Property.Label>
|
||||
<Property.Value>{span.message}</Property.Value>
|
||||
</Property.Item>
|
||||
{span.links && span.links.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Links</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="space-y-1">
|
||||
{span.links.map((link, index) => (
|
||||
<SpanLinkElement key={index} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
</Property.Table>
|
||||
|
||||
{span.events !== undefined && <SpanEvents spanEvents={span.events} />}
|
||||
{span.properties !== undefined && (
|
||||
<CodeBlock
|
||||
rowTitle="Properties"
|
||||
code={span.properties}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineProps = {
|
||||
startTime: Date;
|
||||
duration: number;
|
||||
inProgress: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
export function SpanTimeline({ startTime, duration, inProgress, isError }: TimelineProps) {
|
||||
const state = isError ? "error" : inProgress ? "pending" : "complete";
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-fit max-w-80">
|
||||
<RunTimelineEvent
|
||||
title="Started"
|
||||
subtitle={<DateTimeAccurate date={startTime} />}
|
||||
state="complete"
|
||||
/>
|
||||
{state === "pending" ? (
|
||||
<RunTimelineLine
|
||||
title={
|
||||
<span className="flex items-center gap-1">
|
||||
<Spinner className="size-4" />
|
||||
<span>
|
||||
<LiveTimer startTime={startTime} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
state={"inprogress"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RunTimelineLine
|
||||
title={formatDuration(
|
||||
startTime,
|
||||
new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))
|
||||
)}
|
||||
state={"complete"}
|
||||
/>
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={
|
||||
<DateTimeAccurate
|
||||
date={new Date(startTime.getTime() + nanosecondsToMilliseconds(duration))}
|
||||
/>
|
||||
}
|
||||
state={isError ? "error" : "complete"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpanLinkElement({ link }: { link: SpanLink }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
switch (link.type) {
|
||||
case "run": {
|
||||
return (
|
||||
<TextLink to={v3RunPath(organization, project, { friendlyId: link.runId })}>
|
||||
{link.title}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
case "span": {
|
||||
return (
|
||||
<TextLink to={v3TraceSpanPath(organization, project, link.traceId, link.spanId)}>
|
||||
{link.title}
|
||||
</TextLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function SpanCodePathAccessory({
|
||||
<span className={cn("truncate", "text-text-dimmed")}>{item.text}</span>
|
||||
{index < accessory.items.length - 1 && (
|
||||
<span className="text-text-dimmed">
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -144,6 +144,36 @@ export function eventBackgroundClassName(event: RunEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
export function eventBorderClassName(event: RunEvent) {
|
||||
if (event.isError) {
|
||||
return "border-error";
|
||||
}
|
||||
|
||||
if (event.isCancelled) {
|
||||
return "border-charcoal-600";
|
||||
}
|
||||
|
||||
switch (event.level) {
|
||||
case "TRACE": {
|
||||
return borderClassNameForVariant(event.style.variant, event.isPartial);
|
||||
}
|
||||
case "LOG":
|
||||
case "INFO":
|
||||
case "DEBUG": {
|
||||
return borderClassNameForVariant(event.style.variant, event.isPartial);
|
||||
}
|
||||
case "WARN": {
|
||||
return "border-amber-400";
|
||||
}
|
||||
case "ERROR": {
|
||||
return "border-error";
|
||||
}
|
||||
default: {
|
||||
return borderClassNameForVariant(event.style.variant, event.isPartial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function textClassNameForVariant(variant: TaskEventStyle["variant"]) {
|
||||
switch (variant) {
|
||||
case "primary": {
|
||||
@@ -168,3 +198,17 @@ function backgroundClassNameForVariant(variant: TaskEventStyle["variant"], isPar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function borderClassNameForVariant(variant: TaskEventStyle["variant"], isPartial: boolean) {
|
||||
switch (variant) {
|
||||
case "primary": {
|
||||
if (isPartial) {
|
||||
return "border-blue-500";
|
||||
}
|
||||
return "border-success";
|
||||
}
|
||||
default: {
|
||||
return "border-charcoal-500";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableVariant,
|
||||
} from "../../primitives/Table";
|
||||
import { CancelRunDialog } from "./CancelRunDialog";
|
||||
import { LiveTimer } from "./LiveTimer";
|
||||
@@ -56,6 +59,7 @@ type RunsTableProps = {
|
||||
runs: RunListItem[];
|
||||
isLoading?: boolean;
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -65,6 +69,7 @@ export function TaskRunsTable({
|
||||
runs,
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
}: RunsTableProps) {
|
||||
const user = useUser();
|
||||
const organization = useOrganization();
|
||||
@@ -102,11 +107,11 @@ export function TaskRunsTable({
|
||||
);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Table variant={variant} 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 +215,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 +239,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 +261,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 +290,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) => {
|
||||
@@ -295,7 +303,7 @@ export function TaskRunsTable({
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell to={path} alignment="right">
|
||||
<TableCell to={path} alignment="right" isTabbableCell>
|
||||
{formatNumber(run.number)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
@@ -367,7 +375,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 +413,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 +591,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>
|
||||
);
|
||||
|
||||
@@ -69,3 +69,11 @@ export const RuntimeEnvironmentType = {
|
||||
DEVELOPMENT: "DEVELOPMENT",
|
||||
PREVIEW: "PREVIEW",
|
||||
} as const satisfies Record<RuntimeEnvironmentTypeType, RuntimeEnvironmentTypeType>;
|
||||
|
||||
export function isTaskRunAttemptStatus(value: string): value is keyof typeof TaskRunAttemptStatus {
|
||||
return Object.values(TaskRunAttemptStatus).includes(value as keyof typeof TaskRunAttemptStatus);
|
||||
}
|
||||
|
||||
export function isTaskRunStatus(value: string): value is keyof typeof TaskRunStatus {
|
||||
return Object.values(TaskRunStatus).includes(value as keyof typeof TaskRunStatus);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { logger } from "./services/logger.server";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { $transaction as transac } from "@trigger.dev/database";
|
||||
import { startActiveSpan } from "./v3/tracer.server";
|
||||
import { Span } from "@opentelemetry/api";
|
||||
|
||||
export type {
|
||||
PrismaTransactionClient,
|
||||
@@ -21,25 +23,76 @@ export type {
|
||||
PrismaReplicaClient,
|
||||
};
|
||||
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
name: string,
|
||||
fn: (prisma: PrismaTransactionClient, span?: Span) => Promise<R>,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined>;
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
fn: (prisma: PrismaTransactionClient) => Promise<R>,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined>;
|
||||
export async function $transaction<R>(
|
||||
prisma: PrismaClientOrTransaction,
|
||||
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
|
||||
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined> {
|
||||
return transac(
|
||||
prisma,
|
||||
fn,
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
},
|
||||
options
|
||||
);
|
||||
if (typeof fnOrName === "string") {
|
||||
return await startActiveSpan(fnOrName, async (span) => {
|
||||
span.setAttribute("$transaction", true);
|
||||
|
||||
if (options?.isolationLevel) {
|
||||
span.setAttribute("isolation_level", options.isolationLevel);
|
||||
}
|
||||
|
||||
if (options?.timeout) {
|
||||
span.setAttribute("timeout", options.timeout);
|
||||
}
|
||||
|
||||
if (options?.maxWait) {
|
||||
span.setAttribute("max_wait", options.maxWait);
|
||||
}
|
||||
|
||||
if (options?.swallowPrismaErrors) {
|
||||
span.setAttribute("swallow_prisma_errors", options.swallowPrismaErrors);
|
||||
}
|
||||
|
||||
const fn = fnOrOptions as (prisma: PrismaTransactionClient, span: Span) => Promise<R>;
|
||||
|
||||
return transac(
|
||||
prisma,
|
||||
(client) => fn(client, span),
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
},
|
||||
options
|
||||
);
|
||||
});
|
||||
} else {
|
||||
return transac(
|
||||
prisma,
|
||||
fnOrName,
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
stack: error.stack,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
},
|
||||
typeof fnOrOptions === "function" ? undefined : fnOrOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { Prisma };
|
||||
@@ -58,6 +111,7 @@ function getClient() {
|
||||
const databaseUrl = extendQueryParams(DATABASE_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`);
|
||||
@@ -109,6 +163,7 @@ function getReplicaClient() {
|
||||
const replicaUrl = extendQueryParams(env.DATABASE_READ_REPLICA_URL, {
|
||||
connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(),
|
||||
pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(),
|
||||
connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(),
|
||||
});
|
||||
|
||||
console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`);
|
||||
|
||||
@@ -25,6 +25,13 @@ export default function handleRequest(
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname.startsWith("/login")) {
|
||||
responseHeaders.set("X-Frame-Options", "SAMEORIGIN");
|
||||
responseHeaders.set("Content-Security-Policy", "frame-ancestors 'self'");
|
||||
}
|
||||
|
||||
const acceptLanguage = request.headers.get("accept-language");
|
||||
const locales = parseAcceptLanguage(acceptLanguage, {
|
||||
validate: Intl.DateTimeFormat.supportedLocalesOf,
|
||||
|
||||
@@ -13,6 +13,7 @@ const EnvironmentSchema = z.object({
|
||||
),
|
||||
DATABASE_CONNECTION_LIMIT: z.coerce.number().int().default(10),
|
||||
DATABASE_POOL_TIMEOUT: z.coerce.number().int().default(60),
|
||||
DATABASE_CONNECTION_TIMEOUT: z.coerce.number().int().default(20),
|
||||
DIRECT_URL: z
|
||||
.string()
|
||||
.refine(
|
||||
@@ -32,6 +33,7 @@ const EnvironmentSchema = z.object({
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
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"),
|
||||
@@ -43,9 +45,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"),
|
||||
@@ -83,6 +92,96 @@ const EnvironmentSchema = z.object({
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
REDIS_TLS_DISABLED: z.string().optional(),
|
||||
|
||||
RATE_LIMIT_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
RATE_LIMIT_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
RATE_LIMIT_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
RATE_LIMIT_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
RATE_LIMIT_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
RATE_LIMIT_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
RATE_LIMIT_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
CACHE_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
CACHE_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
CACHE_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
CACHE_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
CACHE_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
CACHE_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
PUBSUB_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
PUBSUB_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
PUBSUB_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
PUBSUB_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
PUBSUB_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
PUBSUB_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
PUBSUB_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
PUBSUB_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
DEFAULT_ENV_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||||
DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT: z.coerce.number().int().default(10),
|
||||
DEFAULT_DEV_ENV_EXECUTION_ATTEMPTS: z.coerce.number().int().positive().default(1),
|
||||
@@ -139,8 +238,15 @@ const EnvironmentSchema = z.object({
|
||||
CONTAINER_REGISTRY_ORIGIN: z.string().optional(),
|
||||
CONTAINER_REGISTRY_USERNAME: z.string().optional(),
|
||||
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
|
||||
ENABLE_REGISTRY_PROXY: z.string().optional(),
|
||||
DEPLOY_REGISTRY_HOST: z.string().optional(),
|
||||
DEPLOY_REGISTRY_USERNAME: z.string().optional(),
|
||||
DEPLOY_REGISTRY_PASSWORD: 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(),
|
||||
@@ -150,6 +256,8 @@ const EnvironmentSchema = z.object({
|
||||
SHARED_QUEUE_CONSUMER_POOL_SIZE: z.coerce.number().int().default(10),
|
||||
SHARED_QUEUE_CONSUMER_INTERVAL_MS: z.coerce.number().int().default(100),
|
||||
SHARED_QUEUE_CONSUMER_NEXT_TICK_INTERVAL_MS: z.coerce.number().int().default(100),
|
||||
SHARED_QUEUE_CONSUMER_EMIT_RESUME_DEPENDENCY_TIMEOUT_MS: z.coerce.number().int().default(1000),
|
||||
SHARED_QUEUE_CONSUMER_RESOLVE_PAYLOADS_BATCH_SIZE: z.coerce.number().int().default(25),
|
||||
|
||||
// Development OTEL environment variables
|
||||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||||
@@ -178,20 +286,61 @@ const EnvironmentSchema = z.object({
|
||||
|
||||
// Internal OTEL environment variables
|
||||
INTERNAL_OTEL_TRACE_EXPORTER_URL: z.string().optional(),
|
||||
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADER_NAME: z.string().optional(),
|
||||
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADER_VALUE: z.string().optional(),
|
||||
INTERNAL_OTEL_TRACE_EXPORTER_AUTH_HEADERS: z.string().optional(),
|
||||
INTERNAL_OTEL_TRACE_LOGGING_ENABLED: z.string().default("1"),
|
||||
// this means 1/20 traces or 5% of traces will be sampled (sampled = recorded)
|
||||
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
|
||||
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
|
||||
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
|
||||
|
||||
INTERNAL_OTEL_LOG_EXPORTER_URL: z.string().optional(),
|
||||
|
||||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
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(),
|
||||
ALERT_RATE_LIMITER_EMISSION_INTERVAL: z.coerce.number().int().default(2_500),
|
||||
ALERT_RATE_LIMITER_BURST_TOLERANCE: z.coerce.number().int().default(10_000),
|
||||
ALERT_RATE_LIMITER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
ALERT_RATE_LIMITER_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
ALERT_RATE_LIMITER_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
ALERT_RATE_LIMITER_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
ALERT_RATE_LIMITER_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
ALERT_RATE_LIMITER_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
ALERT_RATE_LIMITER_REDIS_TLS_DISABLED: z
|
||||
.string()
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
ALERT_RATE_LIMITER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
|
||||
|
||||
@@ -201,6 +350,15 @@ const EnvironmentSchema = z.object({
|
||||
.number()
|
||||
.int()
|
||||
.default(60 * 1000 * 15),
|
||||
MARQS_SHARED_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||||
MARQS_DEV_QUEUE_LIMIT: z.coerce.number().int().default(1000),
|
||||
MARQS_MAXIMUM_NACK_COUNT: z.coerce.number().int().default(64),
|
||||
MARQS_CONCURRENCY_LIMIT_BIAS: z.coerce.number().default(0.75),
|
||||
MARQS_AVAILABLE_CAPACITY_BIAS: z.coerce.number().default(0.3),
|
||||
MARQS_QUEUE_AGE_RANDOMIZATION_BIAS: z.coerce.number().default(0.25),
|
||||
MARQS_REUSE_SNAPSHOT_COUNT: z.coerce.number().int().default(0),
|
||||
MARQS_MAXIMUM_ENV_COUNT: z.coerce.number().int().optional(),
|
||||
|
||||
PROD_TASK_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||||
|
||||
VERBOSE_GRAPHILE_LOGGING: z.string().default("false"),
|
||||
@@ -232,10 +390,105 @@ 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
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB
|
||||
BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB
|
||||
TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(262_144), // 256KB
|
||||
|
||||
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),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
REALTIME_STREAM_TTL: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.default(60 * 60 * 24), // 1 day in seconds
|
||||
BATCH_METADATA_OPERATIONS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
BATCH_METADATA_OPERATIONS_FLUSH_ENABLED: z.string().default("1"),
|
||||
BATCH_METADATA_OPERATIONS_FLUSH_LOGGING_ENABLED: z.string().default("1"),
|
||||
|
||||
LEGACY_RUN_ENGINE_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(1),
|
||||
LEGACY_RUN_ENGINE_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
LEGACY_RUN_ENGINE_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||||
LEGACY_RUN_ENGINE_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
|
||||
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_TLS_DISABLED: z
|
||||
.string()
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
LEGACY_RUN_ENGINE_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
LEGACY_RUN_ENGINE_WAITING_FOR_DEPLOY_BATCH_STAGGER_MS: z.coerce.number().int().default(1_000),
|
||||
|
||||
COMMON_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
COMMON_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
COMMON_WORKER_CONCURRENCY_TASKS_PER_WORKER: z.coerce.number().int().default(10),
|
||||
COMMON_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
COMMON_WORKER_IMMEDIATE_POLL_INTERVAL: z.coerce.number().int().default(50),
|
||||
COMMON_WORKER_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
|
||||
|
||||
COMMON_WORKER_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
COMMON_WORKER_REDIS_READER_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_READER_HOST),
|
||||
COMMON_WORKER_REDIS_READER_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) =>
|
||||
v ?? (process.env.REDIS_READER_PORT ? parseInt(process.env.REDIS_READER_PORT) : undefined)
|
||||
),
|
||||
COMMON_WORKER_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform((v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)),
|
||||
COMMON_WORKER_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
COMMON_WORKER_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
COMMON_WORKER_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
COMMON_WORKER_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
TASK_EVENT_PARTITIONING_ENABLED: z.string().default("0"),
|
||||
TASK_EVENT_PARTITIONED_WINDOW_IN_SECONDS: z.coerce.number().int().default(60), // 1 minute
|
||||
});
|
||||
|
||||
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,6 +3,7 @@ import type { User } from "~/models/user.server";
|
||||
import { loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { useIsImpersonating } from "./useOrganizations";
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
@@ -29,6 +30,7 @@ export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||
|
||||
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||
const user = useOptionalUser(matches);
|
||||
const isImpersonating = useIsImpersonating(matches);
|
||||
|
||||
return Boolean(user?.admin);
|
||||
return Boolean(user?.admin) || isImpersonating;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.serve
|
||||
export const ProjectAlertWebhookProperties = z.object({
|
||||
secret: EncryptedSecretValueSchema,
|
||||
url: z.string(),
|
||||
version: z.string().optional().default("v1"),
|
||||
});
|
||||
|
||||
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getUsername } from "~/utils/username";
|
||||
export type { RuntimeEnvironment };
|
||||
|
||||
export async function findEnvironmentByApiKey(apiKey: string) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
apiKey,
|
||||
},
|
||||
@@ -25,7 +25,7 @@ export async function findEnvironmentByApiKey(apiKey: string) {
|
||||
}
|
||||
|
||||
export async function findEnvironmentByPublicApiKey(apiKey: string) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
pkApiKey: apiKey,
|
||||
},
|
||||
@@ -45,7 +45,7 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
|
||||
}
|
||||
|
||||
export async function findEnvironmentById(id: string) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
@@ -85,7 +85,7 @@ export async function createNewSession(environment: RuntimeEnvironment, ipAddres
|
||||
}
|
||||
|
||||
export async function disconnectSession(environmentId: string) {
|
||||
const environment = await prisma.runtimeEnvironment.findUnique({
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { QueueOptions } from "@trigger.dev/core/v3/schemas";
|
||||
import { TaskQueue } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export async function findQueueInEnvironment(
|
||||
queueName: string,
|
||||
environmentId: string,
|
||||
backgroundWorkerTaskId?: string,
|
||||
backgroundTask?: { queueConfig?: unknown }
|
||||
): Promise<TaskQueue | undefined> {
|
||||
const sanitizedQueueName = sanitizeQueueName(queueName);
|
||||
|
||||
const queue = await prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
name: sanitizedQueueName,
|
||||
},
|
||||
});
|
||||
|
||||
if (queue) {
|
||||
return queue;
|
||||
}
|
||||
|
||||
const task = backgroundTask
|
||||
? backgroundTask
|
||||
: backgroundWorkerTaskId
|
||||
? await prisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
id: backgroundWorkerTaskId,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueConfig = QueueOptions.safeParse(task.queueConfig);
|
||||
|
||||
if (queueConfig.success) {
|
||||
const taskQueueName = queueConfig.data.name
|
||||
? sanitizeQueueName(queueConfig.data.name)
|
||||
: undefined;
|
||||
|
||||
if (taskQueueName && taskQueueName !== sanitizedQueueName) {
|
||||
const queue = await prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
name: taskQueueName,
|
||||
},
|
||||
});
|
||||
|
||||
if (queue) {
|
||||
return queue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow alphanumeric characters, underscores, hyphens, and slashes (and only the first 128 characters)
|
||||
export function sanitizeQueueName(queueName: string) {
|
||||
return queueName.replace(/[^a-zA-Z0-9_\-\/]/g, "").substring(0, 128);
|
||||
}
|
||||
@@ -6,11 +6,10 @@ import type {
|
||||
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,6 +60,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
|
||||
@@ -92,6 +93,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
|
||||
@@ -102,6 +104,7 @@ export function executionResultForTaskRun(
|
||||
return {
|
||||
ok: false,
|
||||
id: taskRun.friendlyId,
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
error: error.data,
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Direction, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
type EventListOptions = {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
direction?: Direction;
|
||||
filterEnvironment?: FilterableEnvironment;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type EventList = Awaited<ReturnType<EventListPresenter["call"]>>;
|
||||
|
||||
export class EventListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
filterEnvironment,
|
||||
direction = "forward",
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
from,
|
||||
to,
|
||||
}: EventListOptions) {
|
||||
const directionMultiplier = direction === "forward" ? 1 : -1;
|
||||
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await this.#prismaClient.eventRecord.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deliverAt: true,
|
||||
deliveredAt: true,
|
||||
isTest: true,
|
||||
createdAt: true,
|
||||
cancelledAt: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
name: {
|
||||
notIn: ["trigger.scheduled", "dev.trigger.scheduled"],
|
||||
},
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
createdAt: {
|
||||
gte: from ? new Date(from).toISOString() : undefined,
|
||||
lte: to ? new Date(to).toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
orderBy: [{ id: "desc" }],
|
||||
//take an extra record to tell if there are more
|
||||
take: directionMultiplier * (pageSize + 1),
|
||||
//skip the cursor if there is one
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor
|
||||
? {
|
||||
id: cursor,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const hasMore = events.length > pageSize;
|
||||
|
||||
//get cursors for next and previous pages
|
||||
let next: string | undefined;
|
||||
let previous: string | undefined;
|
||||
switch (direction) {
|
||||
case "forward":
|
||||
previous = cursor ? events.at(0)?.id : undefined;
|
||||
if (hasMore) {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
case "backward":
|
||||
if (hasMore) {
|
||||
previous = events[1]?.id;
|
||||
next = events[pageSize]?.id;
|
||||
} else {
|
||||
next = events[pageSize - 1]?.id;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const eventsToReturn =
|
||||
direction === "backward" && hasMore
|
||||
? events.slice(1, pageSize + 1)
|
||||
: events.slice(0, pageSize);
|
||||
|
||||
return {
|
||||
events: eventsToReturn.map((event) => ({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
deliverAt: event.deliverAt,
|
||||
deliveredAt: event.deliveredAt,
|
||||
createdAt: event.createdAt,
|
||||
cancelledAt: event.cancelledAt,
|
||||
isTest: event.isTest,
|
||||
environment: {
|
||||
type: event.environment.type,
|
||||
slug: event.environment.slug,
|
||||
userId: event.environment.orgMember?.user.id,
|
||||
userName: getUsername(event.environment.orgMember?.user),
|
||||
},
|
||||
runs: event.runs.length,
|
||||
})),
|
||||
pagination: {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user