Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0324dc1fb2 | |||
| e54aa852d4 | |||
| e0c8fe2912 | |||
| ad94b5b1a6 | |||
| 6811b404f8 | |||
| 3e780860cd | |||
| 5695f57316 | |||
| f371d21b1c | |||
| 72cdb5edc4 | |||
| fefd71934b | |||
| d2d6f99c2e | |||
| 3c60f3f4cf | |||
| b66b46c522 | |||
| ad5fb5f912 | |||
| 1f11a8dde0 | |||
| af427aa32d | |||
| 75d292b06c | |||
| 212f8539c3 | |||
| 9a5e6e58be | |||
| c31700ae5f | |||
| cfb96859b3 | |||
| 9bc641d15e | |||
| a79075908e | |||
| 7f9091f205 | |||
| 9f6887b048 | |||
| 982906cbad | |||
| 90514a73bb | |||
| 2d63c5db50 | |||
| 768036a223 | |||
| 2d8a41b18b | |||
| 67542a54e8 | |||
| f1d89c413b | |||
| 90593adea5 | |||
| 235ab90c3d | |||
| ef7f112227 | |||
| db70faa4c7 | |||
| 1d87b5ea43 | |||
| d3f10941fd | |||
| 6dc556b704 | |||
| b6d1e0d868 | |||
| f2c5243905 | |||
| 803350ae6e | |||
| b225423ce0 | |||
| e36e3d54f2 | |||
| be7790bbdf | |||
| feb4fcdac6 | |||
| a6de3f0ff3 | |||
| 1c80a74885 | |||
| 4c6f27e978 | |||
| 367d7bdb4c | |||
| d998a2064e | |||
| ba96397f53 | |||
| 244988e212 | |||
| b1319fb88c | |||
| cff2ebba49 | |||
| 25c7ff173f | |||
| a6dbf8433c | |||
| 73f8d4f0d7 | |||
| 61c7605b54 | |||
| 8a70ce4cff | |||
| 4f85119dfd | |||
| 94170dec3b | |||
| bf05dac668 | |||
| 215c8ed6b2 | |||
| 2483da251a | |||
| bf15d8aff1 | |||
| 4b0f677774 | |||
| 886429b0ed | |||
| f65157a14f | |||
| 3d67bb81e9 | |||
| d30e9713f4 | |||
| f2babbf637 | |||
| fc609470c4 | |||
| 07f82eae54 | |||
| 13ebfcc5b9 | |||
| 2a04d17e1f | |||
| 002ae4bc0d | |||
| 047cb00417 | |||
| 2c014f725a | |||
| a69e04f03a | |||
| c5488df8ca | |||
| 1caec27f72 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/react-hooks": minor
|
||||
"@trigger.dev/sdk": minor
|
||||
"@trigger.dev/core": minor
|
||||
---
|
||||
|
||||
Access run status updates in realtime, from your server or from your frontend
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix: Handle circular references in flattenAttributes function
|
||||
+3
-17
@@ -7,27 +7,13 @@
|
||||
}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
[
|
||||
"@trigger.dev/*",
|
||||
"trigger.dev"
|
||||
]
|
||||
],
|
||||
"fixed": [["@trigger.dev/*", "trigger.dev"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"webapp",
|
||||
"emails",
|
||||
"proxy",
|
||||
"@trigger.dev/database",
|
||||
"coordinator",
|
||||
"docker-provider",
|
||||
"kubernetes-provider",
|
||||
"@trigger.dev/otlp-importer"
|
||||
],
|
||||
"ignore": ["webapp", "proxy", "coordinator", "docker-provider", "kubernetes-provider"],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix issue with prisma extension breaking deploy builds
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
- Include retries.default in task retry config when indexing
|
||||
- New helpers for internal error retry mechanics
|
||||
- Detection for segfaults and ffmpeg OOM errors
|
||||
- Retries for packet import and export
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/build": patch
|
||||
---
|
||||
|
||||
Added a Vercel sync env vars extension. Given a Vercel projectId and access token it will sync Vercel env vars when deploying Trigger.dev tasks.
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
release:
|
||||
name: 🦋 Changesets Release
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'triggerdotdev/trigger.dev'
|
||||
outputs:
|
||||
published: ${{ steps.changesets.outputs.published }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
unitTests:
|
||||
name: "🧪 Unit Tests"
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -27,5 +27,20 @@ jobs:
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🧪 Run Unit Tests
|
||||
run: pnpm run test
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🧪 Run Webapp Unit Tests
|
||||
run: pnpm run test --filter webapp
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
SESSION_SECRET: "secret"
|
||||
MAGIC_LINK_SECRET: "secret"
|
||||
ENCRYPTION_KEY: "secret"
|
||||
|
||||
- name: 🧪 Run Package Unit Tests
|
||||
run: pnpm run test --filter "@trigger.dev/*"
|
||||
|
||||
- name: 🧪 Run Internal Unit Tests
|
||||
run: pnpm run test --filter "@internal/*"
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
[submodule "packages/otlp-importer/protos"]
|
||||
path = packages/otlp-importer/protos
|
||||
[submodule "internal-packages/otlp-importer/protos"]
|
||||
path = internal-packages/otlp-importer/protos
|
||||
url = https://github.com/open-telemetry/opentelemetry-proto.git
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
Vendored
+2
-6
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
"recommendations": ["bierner.comment-tagged-templates"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ pnpm run db:studio
|
||||
|
||||
## Adding and running migrations
|
||||
|
||||
1. Modify packages/database/prisma/schema.prisma file
|
||||
1. Modify internal-packages/database/prisma/schema.prisma file
|
||||
2. Change directory to the packages/database folder
|
||||
|
||||
```sh
|
||||
|
||||
@@ -15,13 +15,20 @@
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs with no timeouts. Write normal async code, deploy, and never hit a timeout.
|
||||
Trigger.dev is an open source platform and SDK which allows you to create long-running background jobs. Write normal async code, deploy, and never hit a timeout.
|
||||
|
||||
#### Features:
|
||||
### 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
|
||||
- Custom alerts, get notified by email, Slack or webhooks
|
||||
- No infrastructure to manage
|
||||
- Elastic (scaling)
|
||||
- Works with your existing tech stack
|
||||
|
||||
## In your codebase
|
||||
@@ -45,7 +52,7 @@ export const helloWorld = task({
|
||||
|
||||
## Deployment
|
||||
|
||||
Use our SDK to write tasks in your codebase. There's no infrastructure to manage, your tasks automatically scale and connect to our cloud. Or you can always [self-host](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
|
||||
Use our SDK to write tasks in your codebase. There's no infrastructure to manage, your tasks automatically scale and connect to our cloud. Or you can always self-host.
|
||||
|
||||
## Environments
|
||||
|
||||
@@ -59,11 +66,19 @@ View every task in every run so you can tell exactly what happened. We provide a
|
||||
|
||||
# Getting started
|
||||
|
||||
Visit our docs [here](https://trigger.dev/docs/v3/introduction) for a full guide on how to get started with Trigger.dev.
|
||||
The quickest way to get started is to create an account and project in our [web app](https://cloud.trigger.dev), and follow the instructions in the onboarding. Build and deploy your first task in minutes.
|
||||
|
||||
## Self-host
|
||||
### Useful links:
|
||||
|
||||
If you prefer to self-host, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
|
||||
- [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes
|
||||
- [How it works](https://trigger.dev/docs/v3/how-it-works) - understand how Trigger.dev works under the hood
|
||||
- [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases
|
||||
|
||||
## Self-hosting
|
||||
|
||||
If you prefer to self-host Trigger.dev, you can follow our [self-hosting guide](https://trigger.dev/docs/v3/open-source-self-hosting#overview).
|
||||
|
||||
We also have a dedicated self-hosting channel in our [Discord server](https://trigger.dev/discord) for support.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ExponentialBackoff } from "@trigger.dev/core/v3/apps";
|
||||
import { testDockerCheckpoint } from "@trigger.dev/core/v3/apps";
|
||||
import { SimpleLogger } from "@trigger.dev/core/v3/apps";
|
||||
import { nanoid } from "nanoid";
|
||||
import fs from "node:fs/promises";
|
||||
import { ChaosMonkey } from "./chaosMonkey";
|
||||
@@ -8,6 +7,7 @@ import { Buildah, Crictl, Exec } from "./exec";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { TempFileCleaner } from "./cleaner";
|
||||
import { numFromEnv, boolFromEnv } from "./util";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
|
||||
type CheckpointerInitializeReturn = {
|
||||
canCheckpoint: boolean;
|
||||
@@ -86,7 +86,7 @@ export class Checkpointer {
|
||||
#canCheckpoint = false;
|
||||
#dockerMode: boolean;
|
||||
|
||||
#logger = new SimpleLogger("[checkptr]");
|
||||
#logger = new SimpleStructuredLogger("checkpointer");
|
||||
#abortControllers = new Map<string, AbortController>();
|
||||
#failedCheckpoints = new Map<string, unknown>();
|
||||
#waitingForRetry = new Set<string>();
|
||||
@@ -137,7 +137,7 @@ export class Checkpointer {
|
||||
return this.#getInitReturn(true);
|
||||
}
|
||||
|
||||
this.#logger.error(testCheckpoint.message, testCheckpoint.error ?? "");
|
||||
this.#logger.error(testCheckpoint.message, { error: testCheckpoint.error });
|
||||
return this.#getInitReturn(false);
|
||||
}
|
||||
|
||||
@@ -436,7 +436,10 @@ export class Checkpointer {
|
||||
this.#logger.error("Error during cleanup", { ...metadata, error });
|
||||
}
|
||||
|
||||
this.#abortControllers.delete(runId);
|
||||
// Ensure only the current controller is removed
|
||||
if (this.#abortControllers.get(runId) === controller) {
|
||||
this.#abortControllers.delete(runId);
|
||||
}
|
||||
controller.signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SimpleLogger } from "@trigger.dev/core/v3/apps";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
import { Exec } from "./exec";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
@@ -10,14 +10,18 @@ interface TempFileCleanerOptions {
|
||||
}
|
||||
|
||||
export class TempFileCleaner {
|
||||
private logger = new SimpleLogger("[tmp-cleaner]");
|
||||
private enabled = false;
|
||||
private exec = new Exec({ logger: this.logger });
|
||||
|
||||
constructor(private opts: TempFileCleanerOptions) {}
|
||||
private logger: SimpleStructuredLogger;
|
||||
private exec: Exec;
|
||||
|
||||
constructor(private opts: TempFileCleanerOptions) {
|
||||
this.logger = new SimpleStructuredLogger("tmp-cleaner", undefined, { ...this.opts });
|
||||
this.exec = new Exec({ logger: this.logger });
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.logger.log("start", this.opts);
|
||||
this.logger.log("TempFileCleaner.start");
|
||||
this.enabled = true;
|
||||
|
||||
if (!this.opts.leadingEdge) {
|
||||
@@ -28,7 +32,7 @@ export class TempFileCleaner {
|
||||
try {
|
||||
await this.clean();
|
||||
} catch (error) {
|
||||
this.logger.error("error during tick", error);
|
||||
this.logger.error("error during tick", { error });
|
||||
}
|
||||
|
||||
await this.wait();
|
||||
@@ -36,7 +40,7 @@ export class TempFileCleaner {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.logger.log("stop", this.opts);
|
||||
this.logger.log("TempFileCleaner.stop");
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ export class TempFileCleaner {
|
||||
const rmOutput = await rm;
|
||||
|
||||
if (rmOutput.stderr.length > 0) {
|
||||
this.logger.error("delete unsuccessful", rmOutput);
|
||||
this.logger.error("delete unsuccessful", { rmOutput });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SimpleLogger } from "@trigger.dev/core/v3/apps";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homedir } from "os";
|
||||
import { type Result, x } from "tinyexec";
|
||||
@@ -18,7 +18,7 @@ class TinyResult {
|
||||
}
|
||||
|
||||
interface ExecOptions {
|
||||
logger?: SimpleLogger;
|
||||
logger?: SimpleStructuredLogger;
|
||||
abortSignal?: AbortSignal;
|
||||
logOutput?: boolean;
|
||||
trimArgs?: boolean;
|
||||
@@ -26,7 +26,7 @@ interface ExecOptions {
|
||||
}
|
||||
|
||||
export class Exec {
|
||||
private logger: SimpleLogger;
|
||||
private logger: SimpleStructuredLogger;
|
||||
private abortSignal: AbortSignal | undefined;
|
||||
|
||||
private logOutput: boolean;
|
||||
@@ -34,7 +34,7 @@ export class Exec {
|
||||
private neverThrow: boolean;
|
||||
|
||||
constructor(opts: ExecOptions) {
|
||||
this.logger = opts.logger ?? new SimpleLogger();
|
||||
this.logger = opts.logger ?? new SimpleStructuredLogger("exec");
|
||||
this.abortSignal = opts.abortSignal;
|
||||
|
||||
this.logOutput = opts.logOutput ?? true;
|
||||
@@ -64,7 +64,18 @@ export class Exec {
|
||||
command,
|
||||
argsRaw: args,
|
||||
argsTrimmed,
|
||||
...output,
|
||||
globalOpts: {
|
||||
trimArgs: this.trimArgs,
|
||||
neverThrow: this.neverThrow,
|
||||
hasAbortSignal: !!this.abortSignal,
|
||||
},
|
||||
localOpts: opts,
|
||||
stdout: output.stdout,
|
||||
stderr: output.stderr,
|
||||
pid: result.pid,
|
||||
exitCode: result.exitCode,
|
||||
aborted: result.aborted,
|
||||
killed: result.killed,
|
||||
};
|
||||
|
||||
if (this.logOutput) {
|
||||
@@ -103,7 +114,7 @@ interface BuildahOptions {
|
||||
|
||||
export class Buildah {
|
||||
private id: string;
|
||||
private logger: SimpleLogger;
|
||||
private logger: SimpleStructuredLogger;
|
||||
private exec: Exec;
|
||||
|
||||
private containers = new Set<string>();
|
||||
@@ -111,7 +122,7 @@ export class Buildah {
|
||||
|
||||
constructor(opts: BuildahOptions) {
|
||||
this.id = opts.id ?? randomUUID();
|
||||
this.logger = new SimpleLogger(`[buildah][${this.id}]`);
|
||||
this.logger = new SimpleStructuredLogger("buildah", undefined, { id: this.id });
|
||||
|
||||
this.exec = new Exec({
|
||||
logger: this.logger,
|
||||
@@ -220,14 +231,14 @@ interface CrictlOptions {
|
||||
|
||||
export class Crictl {
|
||||
private id: string;
|
||||
private logger: SimpleLogger;
|
||||
private logger: SimpleStructuredLogger;
|
||||
private exec: Exec;
|
||||
|
||||
private archives = new Set<string>();
|
||||
|
||||
constructor(opts: CrictlOptions) {
|
||||
this.id = opts.id ?? randomUUID();
|
||||
this.logger = new SimpleLogger(`[crictl][${this.id}]`);
|
||||
this.logger = new SimpleStructuredLogger("crictl", undefined, { id: this.id });
|
||||
|
||||
this.exec = new Exec({
|
||||
logger: this.logger,
|
||||
|
||||
+676
-400
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ const OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
|
||||
|
||||
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
|
||||
process.env.FORCE_CHECKPOINT_SIMULATION ?? "false"
|
||||
);
|
||||
|
||||
const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
|
||||
|
||||
@@ -34,6 +34,9 @@ const UPTIME_MAX_PENDING_RUNS = Number(process.env.UPTIME_MAX_PENDING_RUNS || "2
|
||||
const UPTIME_MAX_PENDING_INDECES = Number(process.env.UPTIME_MAX_PENDING_INDECES || "10");
|
||||
const UPTIME_MAX_PENDING_ERRORS = Number(process.env.UPTIME_MAX_PENDING_ERRORS || "10");
|
||||
|
||||
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";
|
||||
|
||||
const logger = new SimpleLogger(`[${NODE_NAME}]`);
|
||||
logger.log(`running in ${RUNTIME_ENV} mode`);
|
||||
|
||||
@@ -396,13 +399,13 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
|
||||
get #defaultResourceRequests(): ResourceQuantities {
|
||||
return {
|
||||
"ephemeral-storage": "2Gi",
|
||||
"ephemeral-storage": POD_EPHEMERAL_STORAGE_SIZE_REQUEST,
|
||||
};
|
||||
}
|
||||
|
||||
get #defaultResourceLimits(): ResourceQuantities {
|
||||
return {
|
||||
"ephemeral-storage": "10Gi",
|
||||
"ephemeral-storage": POD_EPHEMERAL_STORAGE_SIZE_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -658,6 +661,7 @@ provider.listen();
|
||||
|
||||
const taskMonitor = new TaskMonitor({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: KUBERNETES_NAMESPACE,
|
||||
onIndexFailure: async (deploymentId, details) => {
|
||||
logger.log("Indexing failed", { deploymentId, details });
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { SimpleLogger } from "@trigger.dev/core/v3/apps";
|
||||
import { EXIT_CODE_ALREADY_HANDLED, EXIT_CODE_CHILD_NONZERO } from "@trigger.dev/core/v3/apps";
|
||||
import { setTimeout } from "timers/promises";
|
||||
import PQueue from "p-queue";
|
||||
import type { Prettify } from "@trigger.dev/core/v3";
|
||||
import { TaskRunErrorCodes, type Prettify, type TaskRunInternalError } from "@trigger.dev/core/v3";
|
||||
|
||||
type FailureDetails = Prettify<{
|
||||
exitCode: number;
|
||||
reason: string;
|
||||
logs: string;
|
||||
overrideCompletion: boolean;
|
||||
errorCode: TaskRunInternalError["code"];
|
||||
}>;
|
||||
|
||||
type IndexFailureHandler = (deploymentId: string, details: FailureDetails) => Promise<any>;
|
||||
@@ -159,19 +160,27 @@ export class TaskMonitor {
|
||||
|
||||
let reason = rawReason || "Unknown error";
|
||||
let logs = rawLogs || "";
|
||||
let overrideCompletion = false;
|
||||
|
||||
/** This will only override existing task errors. It will not crash the run. */
|
||||
let onlyOverrideExistingError = exitCode === EXIT_CODE_CHILD_NONZERO;
|
||||
|
||||
let errorCode: TaskRunInternalError["code"] = TaskRunErrorCodes.POD_UNKNOWN_ERROR;
|
||||
|
||||
switch (rawReason) {
|
||||
case "Error":
|
||||
reason = "Unknown error.";
|
||||
errorCode = TaskRunErrorCodes.POD_UNKNOWN_ERROR;
|
||||
break;
|
||||
case "Evicted":
|
||||
if (message.startsWith("Pod ephemeral local storage usage")) {
|
||||
reason = "Storage limit exceeded.";
|
||||
errorCode = TaskRunErrorCodes.DISK_SPACE_EXCEEDED;
|
||||
} else if (message) {
|
||||
reason = `Evicted: ${message}`;
|
||||
errorCode = TaskRunErrorCodes.POD_EVICTED;
|
||||
} else {
|
||||
reason = "Evicted for unknown reason.";
|
||||
errorCode = TaskRunErrorCodes.POD_EVICTED;
|
||||
}
|
||||
|
||||
if (logs.startsWith("failed to try resolving symlinks")) {
|
||||
@@ -179,10 +188,9 @@ export class TaskMonitor {
|
||||
}
|
||||
break;
|
||||
case "OOMKilled":
|
||||
overrideCompletion = true;
|
||||
reason = `${
|
||||
exitCode === EXIT_CODE_CHILD_NONZERO ? "Child process" : "Parent process"
|
||||
} ran out of memory! Try choosing a machine preset with more memory for this task.`;
|
||||
reason =
|
||||
"[TaskMonitor] Your task ran out of memory. Try increasing the machine specs. If this doesn't fix it there might be a memory leak.";
|
||||
errorCode = TaskRunErrorCodes.TASK_PROCESS_OOM_KILLED;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -192,7 +200,8 @@ export class TaskMonitor {
|
||||
exitCode,
|
||||
reason,
|
||||
logs,
|
||||
overrideCompletion,
|
||||
overrideCompletion: onlyOverrideExistingError,
|
||||
errorCode,
|
||||
} satisfies FailureDetails;
|
||||
|
||||
const app = pod.metadata?.labels?.app;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev"
|
||||
"dev": "wrangler dev",
|
||||
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
|
||||
+2
-20
@@ -17,12 +17,9 @@ export interface Env {
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
if (!env.REWRITE_HOSTNAME) throw new Error("Missing REWRITE_HOSTNAME");
|
||||
console.log("url", request.url);
|
||||
|
||||
if (!queueingIsEnabled(env)) {
|
||||
console.log("Missing AWS credentials. Passing through to the origin.");
|
||||
return redirectToOrigin(request, env);
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -42,25 +39,10 @@ export default {
|
||||
}
|
||||
|
||||
//the same request but with the hostname (and port) changed
|
||||
return redirectToOrigin(request, env);
|
||||
return fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
function redirectToOrigin(request: Request, env: Env) {
|
||||
const newUrl = new URL(request.url);
|
||||
newUrl.hostname = env.REWRITE_HOSTNAME;
|
||||
newUrl.port = env.REWRITE_PORT || newUrl.port;
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
};
|
||||
|
||||
console.log("rewritten url", newUrl.toString());
|
||||
return fetch(newUrl.toString(), requestInit);
|
||||
}
|
||||
|
||||
function queueingIsEnabled(env: Env) {
|
||||
return (
|
||||
env.AWS_SQS_ACCESS_KEY_ID &&
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1, Header3 } from "./primitives/Headers";
|
||||
import { motion } from "framer-motion";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
button?: {
|
||||
@@ -39,12 +42,32 @@ type DisplayOptionsProps = {
|
||||
|
||||
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header1 className="mb-4 border-b border-charcoal-800 pb-4">{title}</Header1>
|
||||
{message && <Header3>{message}</Header3>}
|
||||
<LinkButton to={button ? button.to : "/"} variant="primary/medium" className="mt-8">
|
||||
{button ? button.title : "Home"}
|
||||
</LinkButton>
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-[#16181C]">
|
||||
<div className="z-10 mt-[30vh] flex flex-col items-center gap-8">
|
||||
<Header1>{title}</Header1>
|
||||
{message && <Paragraph>{message}</Paragraph>}
|
||||
<LinkButton
|
||||
to={button ? button.to : "/"}
|
||||
shortcut={{ modifiers: ["meta"], key: "g" }}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={HomeIcon}
|
||||
>
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<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" }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ChevronRightIcon,
|
||||
EnvelopeIcon,
|
||||
LifebuoyIcon,
|
||||
LightBulbIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { InformationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { ActivityIcon } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsTroubleshootingPath } from "~/utils/pathBuilder";
|
||||
import { Button, LinkButton } from "./primitives/Buttons";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
|
||||
import { Fieldset } from "./primitives/Fieldset";
|
||||
import { FormButtons } from "./primitives/FormButtons";
|
||||
import { FormError } from "./primitives/FormError";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Icon } from "./primitives/Icon";
|
||||
import { InfoPanel } from "./primitives/InfoPanel";
|
||||
import { InputGroup } from "./primitives/InputGroup";
|
||||
import { Label } from "./primitives/Label";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Select, SelectItem } from "./primitives/Select";
|
||||
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -37,161 +29,111 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
const location = useLocation();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const [type, setType] = useState<FeedbackType>(defaultValue);
|
||||
|
||||
const [form, { path, feedbackType, message }] = useForm({
|
||||
id: "accept-invite",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onInput",
|
||||
});
|
||||
|
||||
if (
|
||||
open &&
|
||||
navigation.formAction === "/resources/feedback" &&
|
||||
form.error === undefined &&
|
||||
form.errors.length === 0
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (
|
||||
navigation.formAction === "/resources/feedback" &&
|
||||
navigation.state === "loading" &&
|
||||
form.error === undefined &&
|
||||
form.errors.length === 0
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [navigation, form]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild={true}>{button}</SheetTrigger>
|
||||
<SheetContent className="@container">
|
||||
<SheetBody className="flex h-full flex-col justify-between">
|
||||
<LinkBanner
|
||||
title="Join our Discord community"
|
||||
icon={<DiscordIcon className="size-9" />}
|
||||
to="https://trigger.dev/discord"
|
||||
className="hover:border-text-link"
|
||||
>
|
||||
<Paragraph>The quickest way to get answers from the Trigger.dev community.</Paragraph>
|
||||
</LinkBanner>
|
||||
<LinkBanner
|
||||
title="Book a 15 min chat with the founders"
|
||||
icon={<CalendarDaysIcon className="size-9 text-green-500" />}
|
||||
to="https://cal.com/team/triggerdotdev/founders-call"
|
||||
className="hover:border-green-500"
|
||||
>
|
||||
<Paragraph>Have a question or want to chat? Book a time to talk with us.</Paragraph>
|
||||
</LinkBanner>
|
||||
<LinkBanner
|
||||
title="Suggest a feature"
|
||||
icon={<LightBulbIcon className="size-9 text-sun-500" />}
|
||||
to="https://feedback.trigger.dev/"
|
||||
className="hover:border-sun-400"
|
||||
>
|
||||
<Paragraph>Have an idea for a new feature or improvement? Let us know!</Paragraph>
|
||||
</LinkBanner>
|
||||
<LinkBanner
|
||||
title="Troubleshooting"
|
||||
icon={<LifebuoyIcon className="size-9 text-rose-500" />}
|
||||
>
|
||||
<Paragraph>
|
||||
If you're having trouble, check out our troubleshooting guide or the Trigger.dev
|
||||
Status page.
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Contact us</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={EnvelopeIcon} className="size-10 min-w-[2.5rem] text-blue-500" />
|
||||
<Paragraph variant="base/bright">
|
||||
How can we help? We read every message and will respond as quickly as we can.
|
||||
</Paragraph>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LinkButton
|
||||
to={docsTroubleshootingPath("")}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Troubleshooting Docs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={"https://status.trigger.dev/"}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={ActivityIcon}
|
||||
>
|
||||
Trigger.dev Status
|
||||
</LinkButton>
|
||||
</div>
|
||||
</LinkBanner>
|
||||
<LinkBanner
|
||||
title="Send us an email"
|
||||
icon={<EnvelopeIcon className="size-9 text-blue-500" />}
|
||||
>
|
||||
<Paragraph>We read every message and respond quickly.</Paragraph>
|
||||
<Form method="post" action="/resources/feedback" {...form.props} className="w-full">
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup className="max-w-full">
|
||||
<Select
|
||||
{...conform.select(feedbackType)}
|
||||
variant="tertiary/medium"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="Select type"
|
||||
text={(value) => feedbackTypeLabel[value]}
|
||||
dropdownIcon
|
||||
</div>
|
||||
<hr className="border-charcoal-800" />
|
||||
<Form method="post" action="/resources/feedback" {...form.props} className="w-full">
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup className="max-w-full">
|
||||
{type === "feature" && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>Message</Label>
|
||||
<TextArea {...conform.textarea(message)} />
|
||||
<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>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</LinkBanner>
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkBanner({
|
||||
className,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
to,
|
||||
}: {
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
to?: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
"group/banner mb-4 flex w-full items-center justify-between rounded-md border border-grid-bright bg-charcoal-750 p-4 transition",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-start gap-4">
|
||||
<span>{icon}</span>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Header1 className="text-2xl font-semibold text-text-bright">{title}</Header1>
|
||||
{children}
|
||||
<Paragraph variant="small">
|
||||
All our feature requests are public and voted on by the community. The best
|
||||
way to submit your feature request is to{" "}
|
||||
<TextLink to="https://feedback.trigger.dev">
|
||||
post it to our feedback forum
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
{type === "help" && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
The quickest way to get answers from the Trigger.dev team and community is to{" "}
|
||||
<TextLink to="https://trigger.dev/discord">ask in our Discord</TextLink>.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
<Select
|
||||
{...conform.select(feedbackType)}
|
||||
variant="tertiary/medium"
|
||||
value={type}
|
||||
defaultValue={type}
|
||||
setValue={(v) => setType(v as FeedbackType)}
|
||||
placeholder="Select type"
|
||||
text={(value) => feedbackTypeLabel[value as FeedbackType]}
|
||||
dropdownIcon
|
||||
>
|
||||
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>Message</Label>
|
||||
<TextArea {...conform.textarea(message)} />
|
||||
<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>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
{to && (
|
||||
<ChevronRightIcon className="size-5 text-charcoal-500 transition group-hover:translate-x-1 group-hover/banner:text-text-bright" />
|
||||
)}
|
||||
</a>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
|
||||
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
|
||||
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
|
||||
</div>
|
||||
<Link to={to} className="text-2sm text-text-link">
|
||||
<Link to={to} className="text-2sm text-text-link focus-custom">
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -241,7 +241,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"absolute right-3 z-50 transition-colors duration-100 hover:cursor-pointer",
|
||||
"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"
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
<SideMenuItem
|
||||
name="Your profile"
|
||||
icon="account"
|
||||
iconColor="text-indigo-500"
|
||||
activeIconColor="text-indigo-500"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
@@ -49,7 +49,7 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
iconColor="text-emerald-500"
|
||||
activeIconColor="text-emerald-500"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowUpRightIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
CalendarDaysIcon,
|
||||
ChartBarIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
CursorArrowRaysIcon,
|
||||
EnvelopeIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
LightBulbIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -17,13 +22,14 @@ import {
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { FeedbackType } from "~/routes/resources.feedback";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
@@ -77,11 +83,12 @@ import {
|
||||
PopoverCustomTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverSideMenuTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
|
||||
@@ -91,6 +98,8 @@ type SideMenuProps = {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
button?: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
};
|
||||
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
@@ -171,7 +180,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
to={organizationTeamPath(organization)}
|
||||
iconColor="text-amber-500"
|
||||
activeIconColor="text-amber-500"
|
||||
data-action="team"
|
||||
/>
|
||||
{organization.projects.some((proj) => proj.version === "V3") && isManagedCloud && (
|
||||
@@ -180,14 +189,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
to={v3UsagePath(organization)}
|
||||
iconColor="text-green-600"
|
||||
activeIconColor="text-green-600"
|
||||
data-action="usage"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
to={v3BillingPath(organization)}
|
||||
iconColor="text-blue-600"
|
||||
activeIconColor="text-blue-600"
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying
|
||||
@@ -202,14 +211,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
name="Usage (v2)"
|
||||
icon={ChartBarIcon}
|
||||
to={organizationBillingPath(organization)}
|
||||
iconColor="text-green-600"
|
||||
activeIconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
activeIconColor="text-teal-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="organization-settings"
|
||||
/>
|
||||
@@ -239,96 +248,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
{currentPlan?.v3Subscription?.plan?.limits.support === "slack" && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={SlackIcon}
|
||||
data-action="join our slack"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Join our Slack
|
||||
</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="primary/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>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Status"
|
||||
icon={SignalIcon}
|
||||
to="https://status.trigger.dev/"
|
||||
data-action="status"
|
||||
target="_blank"
|
||||
/>
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon="log"
|
||||
leadingIconClassName="text-primary"
|
||||
data-action="help & feedback"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
<span className="text-primary">Help & Feedback</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<HelpAndFeedback />
|
||||
{isV3Project && isFreeV3User && (
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
@@ -341,6 +261,167 @@ 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,
|
||||
@@ -495,47 +576,47 @@ function V2ProjectSideMenu({
|
||||
<SideMenuItem
|
||||
name="Jobs"
|
||||
icon="job"
|
||||
iconColor="text-indigo-500"
|
||||
activeIconColor="text-indigo-500"
|
||||
to={projectPath(organization, project)}
|
||||
data-action="jobs"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
activeIconColor="text-teal-500"
|
||||
to={projectRunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Triggers"
|
||||
icon="trigger"
|
||||
iconColor="text-amber-500"
|
||||
activeIconColor="text-amber-500"
|
||||
to={projectTriggersPath(organization, project)}
|
||||
data-action="triggers"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Events"
|
||||
icon={CursorArrowRaysIcon}
|
||||
iconColor="text-sky-500"
|
||||
activeIconColor="text-sky-500"
|
||||
to={projectEventsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="HTTP endpoints"
|
||||
icon="http-endpoint"
|
||||
iconColor="text-pink-500"
|
||||
activeIconColor="text-pink-500"
|
||||
to={projectHttpEndpointsPath(organization, project)}
|
||||
data-action="httpendpoints"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environments & API Keys"
|
||||
icon="environment"
|
||||
iconColor="text-rose-500"
|
||||
activeIconColor="text-rose-500"
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
activeIconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
@@ -558,41 +639,41 @@ function V3ProjectSideMenu({
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIcon}
|
||||
iconColor="text-blue-500"
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3ProjectPath(organization, project)}
|
||||
data-action="tasks"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon="runs"
|
||||
iconColor="text-teal-500"
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
iconColor="text-lime-500"
|
||||
activeIconColor="text-lime-500"
|
||||
to={v3TestPath(organization, project)}
|
||||
data-action="test"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
iconColor="text-sun-500"
|
||||
activeIconColor="text-sun-500"
|
||||
to={v3SchedulesPath(organization, project)}
|
||||
data-action="schedules"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
iconColor="text-amber-500"
|
||||
activeIconColor="text-amber-500"
|
||||
to={v3ApiKeysPath(organization, project)}
|
||||
data-action="api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
iconColor="text-pink-500"
|
||||
activeIconColor="text-pink-500"
|
||||
to={v3EnvironmentVariablesPath(organization, project)}
|
||||
data-action="environment variables"
|
||||
/>
|
||||
@@ -600,7 +681,7 @@ function V3ProjectSideMenu({
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
iconColor="text-blue-500"
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
@@ -608,7 +689,7 @@ function V3ProjectSideMenu({
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
@@ -616,14 +697,14 @@ function V3ProjectSideMenu({
|
||||
<SideMenuItem
|
||||
name="Concurrency limits"
|
||||
icon={RectangleStackIcon}
|
||||
iconColor="text-indigo-500"
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3ConcurrencyPath(organization, project)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3ProjectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
|
||||
@@ -3,25 +3,26 @@ import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type IconNames } from "../primitives/NamedIcon";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
iconColor,
|
||||
activeIconColor,
|
||||
inactiveIconColor,
|
||||
trailingIcon,
|
||||
trailingIconClassName,
|
||||
name,
|
||||
to,
|
||||
hasWarning,
|
||||
badge,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: IconNames | React.ComponentType<any>;
|
||||
iconColor?: string;
|
||||
activeIconColor?: string;
|
||||
inactiveIconColor?: string;
|
||||
trailingIcon?: IconNames | React.ComponentType<any>;
|
||||
trailingIconClassName?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
hasWarning?: string | boolean;
|
||||
badge?: string;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
@@ -35,11 +36,13 @@ export function SideMenuItem({
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? iconColor : "text-text-dimmed"}
|
||||
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
|
||||
TrailingIcon={trailingIcon}
|
||||
trailingIconClassName={trailingIconClassName}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750",
|
||||
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
subItem ? "text-text-dimmed" : "",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
)}
|
||||
@@ -48,27 +51,13 @@ export function SideMenuItem({
|
||||
{name}
|
||||
<div className="flex items-center gap-1">
|
||||
{badge !== undefined && <MenuCount count={badge} />}
|
||||
{typeof hasWarning === "string" ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon icon={IconExclamationCircle} className="h-5 w-5 text-error" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1 border border-error bg-error/20 backdrop-blur-xl">
|
||||
{hasWarning}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
hasWarning && <Icon icon={IconExclamationCircle} className="h-5 w-5 text-error" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuCount({ count }: { count: number | string }) {
|
||||
export function MenuCount({ count }: { count: number | string }) {
|
||||
return (
|
||||
<div className="rounded-full bg-charcoal-900 px-2 py-1 text-xxs uppercase tracking-wider text-text-dimmed">
|
||||
{count}
|
||||
|
||||
@@ -45,7 +45,10 @@ export function AppliedFilter({
|
||||
</div>
|
||||
{removable && (
|
||||
<button
|
||||
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
|
||||
className={cn(
|
||||
"group flex size-6 items-center justify-center focus-custom",
|
||||
variantClassName.clear
|
||||
)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
|
||||
@@ -48,11 +48,11 @@ const theme = {
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
"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",
|
||||
shortcut:
|
||||
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright group-hover:border-text-dimmed",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
@@ -114,8 +114,7 @@ const variant = {
|
||||
"danger/extra-large": createVariant("extra-large", "danger"),
|
||||
"menu-item": {
|
||||
textColor: "text-text-bright px-1",
|
||||
button:
|
||||
"h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-800 transition",
|
||||
button: "h-9 px-[0.475rem] text-sm rounded-sm bg-transparent group-hover:bg-charcoal-750",
|
||||
icon: "h-5",
|
||||
iconSpacing: "gap-x-0.5",
|
||||
shortcutVariant: undefined,
|
||||
@@ -124,7 +123,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-850 transition",
|
||||
"h-[1.8rem] px-[0.4rem] text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750",
|
||||
icon: "h-4",
|
||||
iconSpacing: "gap-x-1.5",
|
||||
shortcutVariant: undefined,
|
||||
@@ -133,7 +132,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-850 transition",
|
||||
"h-[1.8rem] px-[0.5rem] ml-5 text-2sm rounded-sm text-text-dimmed bg-transparent group-hover:bg-charcoal-750 focus-custom",
|
||||
icon: undefined,
|
||||
iconSpacing: undefined,
|
||||
shortcutVariant: undefined,
|
||||
@@ -142,7 +141,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",
|
||||
$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",
|
||||
variant: variant,
|
||||
};
|
||||
|
||||
@@ -268,7 +267,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group outline-none focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
@@ -328,7 +327,7 @@ export const LinkButton = ({
|
||||
<ExtLink
|
||||
href={to.toString()}
|
||||
ref={innerRef}
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -343,7 +342,7 @@ export const LinkButton = ({
|
||||
<Link
|
||||
to={to}
|
||||
ref={innerRef}
|
||||
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
|
||||
className={cn("group focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseEnter={onMouseEnter}
|
||||
|
||||
@@ -61,6 +61,7 @@ export type CheckboxProps = Omit<
|
||||
description?: string;
|
||||
badges?: string[];
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
onChange?: (isChecked: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -78,6 +79,7 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
badges,
|
||||
disabled,
|
||||
className,
|
||||
labelClassName: externalLabelClassName,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -148,7 +150,8 @@ export const CheckboxWithLabel = React.forwardRef<HTMLInputElement, CheckboxProp
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
labelClassName
|
||||
labelClassName,
|
||||
externalLabelClassName
|
||||
)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -50,7 +50,7 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
<hr className="absolute left-0 top-11 w-full" />
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus-visible:ring-ring absolute right-3 top-3 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none">
|
||||
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-3 top-2 rounded-sm py-1 pr-1 opacity-70 transition-opacity focus-custom hover:opacity-100 disabled:pointer-events-none">
|
||||
<div className="flex gap-x-2">
|
||||
<ShortcutKey
|
||||
shortcut={{
|
||||
@@ -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-end sm:space-x-2", className)}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LinkButton } from "./Buttons";
|
||||
import { Header3 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "./Buttons";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const variants = {
|
||||
info: {
|
||||
@@ -57,7 +57,7 @@ export function InfoPanel({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{title && <Header3 className="text-text-bright">{title}</Header3>}
|
||||
{title && <Header2 className="text-text-bright">{title}</Header2>}
|
||||
{typeof children === "string" ? (
|
||||
<Paragraph variant={"small"} className="text-text-dimmed">
|
||||
{children}
|
||||
|
||||
@@ -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-ring 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-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";
|
||||
|
||||
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";
|
||||
|
||||
@@ -49,7 +49,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
|
||||
<div className="group -ml-1.5 flex items-center gap-0">
|
||||
<Link
|
||||
to={backButton.to}
|
||||
className="rounded px-1.5 py-1 text-xs text-text-dimmed transition group-hover:bg-charcoal-700 group-hover:text-text-bright"
|
||||
className="rounded px-1.5 py-1 text-xs text-text-dimmed transition focus-custom group-hover:bg-charcoal-700 group-hover:text-text-bright"
|
||||
>
|
||||
{backButton.text}
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
|
||||
import { Paragraph, type ParagraphVariant } from "./Paragraph";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
@@ -91,7 +93,7 @@ function PopoverCustomTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition hover:bg-charcoal-850 hover:text-text-bright",
|
||||
"group flex items-center justify-end gap-1 rounded text-text-dimmed transition focus-custom hover:bg-charcoal-850 hover:text-text-bright",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -100,6 +102,45 @@ function PopoverCustomTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverSideMenuTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
...props
|
||||
}: { isOpen?: boolean; shortcut?: ShortcutDefinition } & React.ComponentPropsWithoutRef<
|
||||
typeof PopoverTrigger
|
||||
>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut ? "justify-between" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrowTrigger({
|
||||
isOpen,
|
||||
children,
|
||||
@@ -116,7 +157,7 @@ function PopoverArrowTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright",
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
|
||||
fullWidth && "w-full justify-between",
|
||||
className
|
||||
)}
|
||||
@@ -149,7 +190,7 @@ 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 hover:bg-charcoal-750 hover:text-text-bright",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -165,6 +206,7 @@ export {
|
||||
PopoverCustomTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverSideMenuTrigger,
|
||||
PopoverTrigger,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
@@ -81,7 +81,9 @@ export function RadioButtonCircle({
|
||||
outerCircleClassName
|
||||
)}
|
||||
>
|
||||
<Circle className={cn("h-1.5 w-1.5 fill-white text-white", innerCircleClassName)} />
|
||||
<Circle
|
||||
className={cn("size-1.5 fill-text-bright text-text-bright", innerCircleClassName)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -121,7 +123,7 @@ export const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-start gap-x-2 transition",
|
||||
"group flex cursor-pointer items-start gap-x-2 transition focus-custom",
|
||||
variation.button,
|
||||
className
|
||||
)}
|
||||
@@ -129,7 +131,7 @@ export const RadioGroupItem = React.forwardRef<
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background focus-visible:ring-ring aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variation.inputPosition
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<PanelResizer
|
||||
className={cn(
|
||||
"focus-visible:ring-ring group relative flex w-0.75 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary/50 focus-visible:ring-offset-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
size="3px"
|
||||
|
||||
@@ -65,10 +65,7 @@ export default function SegmentedControl({
|
||||
value={option.value}
|
||||
className={({ active, checked }) =>
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
|
||||
active
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus-custom",
|
||||
checked
|
||||
? variants[variant].active
|
||||
: "text-text-dimmed transition hover:text-text-bright"
|
||||
|
||||
@@ -15,18 +15,18 @@ const sizes = {
|
||||
button: "h-6 rounded text-xs px-2 ",
|
||||
},
|
||||
medium: {
|
||||
button: "h-8 rounded text-xs px-3 text-sm",
|
||||
button: "h-8 rounded px-3 text-sm",
|
||||
},
|
||||
};
|
||||
|
||||
const style = {
|
||||
tertiary: {
|
||||
button:
|
||||
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
|
||||
"bg-tertiary focus-custom border border-tertiary hover:text-text-bright hover:border-charcoal-600",
|
||||
},
|
||||
minimal: {
|
||||
button:
|
||||
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
|
||||
"bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -322,7 +322,7 @@ export function SelectTrigger({
|
||||
render={
|
||||
<Ariakit.Select
|
||||
className={cn(
|
||||
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"group flex items-center gap-1 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variantClasses.button,
|
||||
className
|
||||
)}
|
||||
@@ -426,7 +426,7 @@ export function SelectList(props: SelectListProps) {
|
||||
<Component
|
||||
{...props}
|
||||
className={cn(
|
||||
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
"overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
@@ -440,11 +440,11 @@ export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
|
||||
"group cursor-pointer px-1 pt-1 text-sm text-text-dimmed focus-custom last:pb-1";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
|
||||
shortcut,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
@@ -477,7 +477,7 @@ export function SelectItem({
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
const variations = {
|
||||
large: {
|
||||
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition",
|
||||
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",
|
||||
},
|
||||
small: {
|
||||
container:
|
||||
"flex items-center gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition",
|
||||
"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",
|
||||
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",
|
||||
@@ -38,14 +38,14 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"group-focus-visible:ring-ring group-focus-visible:ring-offset-background peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-secondary group-data-[state=unchecked]:bg-charcoal-700 focus-visible:outline-none",
|
||||
"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",
|
||||
root
|
||||
)}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
thumb,
|
||||
"pointer-events-none block rounded-full bg-charcoal-200 shadow-lg ring-0 transition group-data-[state=checked]:bg-charcoal-700"
|
||||
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ const rowHoverStyles = {
|
||||
};
|
||||
|
||||
const stickyStyles =
|
||||
"sticky right-0 z-10 w-[2.8rem] min-w-[2.8rem] bg-background-dimmed before:absolute before:pointer-events-none before:-left-8 before:top-0 before:h-full before:min-w-[2rem]";
|
||||
"sticky right-0 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>(
|
||||
(
|
||||
@@ -202,11 +202,11 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
colSpan={colSpan}
|
||||
>
|
||||
{to ? (
|
||||
<Link to={to} className={cn(flexClasses, actionClassName)}>
|
||||
<Link to={to} className={cn("focus-custom", flexClasses, actionClassName)}>
|
||||
{children}
|
||||
</Link>
|
||||
) : onClick ? (
|
||||
<button onClick={onClick} className={cn(flexClasses, actionClassName)}>
|
||||
<button onClick={onClick} className={cn("focus-custom", flexClasses, actionClassName)}>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -46,7 +46,7 @@ export function TabLink({
|
||||
layoutId: string;
|
||||
}) {
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1" end>
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end>
|
||||
{({ isActive, isPending }) => {
|
||||
return (
|
||||
<>
|
||||
@@ -96,7 +96,7 @@ export function TabButton({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn("group flex flex-col items-center pt-1", props.className)}
|
||||
className={cn("group flex flex-col items-center pt-1 focus-custom", props.className)}
|
||||
type="button"
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function TextArea({ className, rows, ...props }: TextAreaProps) {
|
||||
{...props}
|
||||
rows={rows ?? 6}
|
||||
className={cn(
|
||||
"ring-offset-background placeholder:text-muted-foreground focus:border-ring focus:ring-ring focus-visible:ring-ring w-full rounded-md border border-tertiary bg-tertiary px-3 text-sm text-text-bright transition file:border-0 file:bg-transparent file:text-base file:font-medium hover:border-charcoal-600 focus:outline-none focus:ring-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"placeholder:text-muted-foreground w-full rounded border border-charcoal-800 bg-charcoal-750 px-3 text-sm text-text-bright transition focus-custom focus-custom file:border-0 file:bg-transparent file:text-base file:font-medium hover:border-charcoal-600 hover:bg-charcoal-650 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 focus-visible:outline-none",
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
@@ -72,7 +72,9 @@ function SimpleTooltip({
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className={cn("h-fit", buttonClassName)}>{button}</TooltipTrigger>
|
||||
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)}>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
hidden={hidden}
|
||||
|
||||
@@ -368,7 +368,7 @@ function AppliedStatusFilter() {
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
|
||||
@@ -456,7 +456,7 @@ function AppliedEnvironmentFilter({
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
@@ -547,7 +547,7 @@ function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTas
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
value={appliedSummary(
|
||||
@@ -645,7 +645,7 @@ function AppliedBulkActionsFilter({ bulkActions }: Pick<RunFiltersProps, "bulkAc
|
||||
{(search, setSearch) => (
|
||||
<BulkActionsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Bulk action"
|
||||
value={bulkId}
|
||||
@@ -764,7 +764,7 @@ function AppliedTagsFilter() {
|
||||
{(search, setSearch) => (
|
||||
<TagsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Tags"
|
||||
value={appliedSummary(values("tags"))}
|
||||
@@ -902,7 +902,7 @@ function AppliedPeriodFilter() {
|
||||
{(search, setSearch) => (
|
||||
<CreatedDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { formatDuration, formatDurationMilliseconds, TaskRunError } from "@trigger.dev/core/v3";
|
||||
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";
|
||||
@@ -35,7 +40,7 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
import { TraceSpan } from "~/utils/taskEvent";
|
||||
import { SpanLink } from "~/v3/eventRepository.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { RunTimelineEvent, RunTimelineLine } from "./InspectorTimeline";
|
||||
import { RunTag } from "./RunTag";
|
||||
import { TaskRunStatusCombo } from "./TaskRunStatus";
|
||||
@@ -70,7 +75,7 @@ export function RunInspector({
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
|
||||
<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")}>
|
||||
@@ -96,7 +101,7 @@ export function RunInspector({
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
|
||||
<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"}
|
||||
@@ -474,6 +479,7 @@ function RunTimeline({ run }: { run: RawRun }) {
|
||||
const updatedAt = new Date(run.updatedAt);
|
||||
|
||||
const isFinished = isFinalRunStatus(run.status);
|
||||
const isError = isFailedRunStatus(run.status);
|
||||
|
||||
return (
|
||||
<div className="min-w-fit max-w-80">
|
||||
@@ -530,7 +536,7 @@ function RunTimeline({ run }: { run: RawRun }) {
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={<DateTimeAccurate date={updatedAt} />}
|
||||
state="complete"
|
||||
state={isError ? "error" : "complete"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -553,7 +559,9 @@ function RunTimeline({ run }: { run: RawRun }) {
|
||||
}
|
||||
|
||||
function RunError({ error }: { error: TaskRunError }) {
|
||||
switch (error.type) {
|
||||
const enhancedError = taskRunErrorEnhancer(error);
|
||||
|
||||
switch (enhancedError.type) {
|
||||
case "STRING_ERROR":
|
||||
case "CUSTOM_ERROR": {
|
||||
return (
|
||||
@@ -561,7 +569,7 @@ function RunError({ error }: { error: TaskRunError }) {
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={error.raw}
|
||||
code={enhancedError.raw}
|
||||
maxLines={20}
|
||||
/>
|
||||
</div>
|
||||
@@ -569,16 +577,21 @@ function RunError({ error }: { error: TaskRunError }) {
|
||||
}
|
||||
case "BUILT_IN_ERROR":
|
||||
case "INTERNAL_ERROR": {
|
||||
const name = "name" in error ? error.name : error.code;
|
||||
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>
|
||||
{error.message && <Callout variant="error">{error.message}</Callout>}
|
||||
{error.stackTrace && (
|
||||
{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={error.stackTrace}
|
||||
code={enhancedError.stackTrace}
|
||||
maxLines={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
exceptionEventEnhancer,
|
||||
isExceptionSpanEvent,
|
||||
type ExceptionEventProperties,
|
||||
type SpanEvent as OtelSpanEvent,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
@@ -64,19 +68,41 @@ export function SpanEventError({
|
||||
spanEvent: OtelSpanEvent;
|
||||
exception: ExceptionEventProperties;
|
||||
}) {
|
||||
const enhancedException = exceptionEventEnhancer(exception);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-sm border border-rose-500/50 px-3 pb-3 pt-2">
|
||||
<SpanEventHeader
|
||||
title={exception.type ?? "Error"}
|
||||
title={enhancedException.type ?? "Error"}
|
||||
time={spanEvent.time}
|
||||
titleClassName="text-rose-500"
|
||||
/>
|
||||
{exception.message && <Callout variant="error">{exception.message}</Callout>}
|
||||
{exception.stacktrace && (
|
||||
{enhancedException.message && <Callout variant="error">{enhancedException.message}</Callout>}
|
||||
{enhancedException.link &&
|
||||
(enhancedException.link.magic === "CONTACT_FORM" ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-400"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
{enhancedException.link.name}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Callout variant="docs" to={enhancedException.link.href}>
|
||||
{enhancedException.link.name}
|
||||
</Callout>
|
||||
))}
|
||||
{enhancedException.stacktrace && (
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={exception.stacktrace}
|
||||
code={enhancedException.stacktrace}
|
||||
maxLines={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function SpanInspector({
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
|
||||
<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}
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
import { Prisma, PrismaClient } from "@trigger.dev/database";
|
||||
import {
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
PrismaClientOrTransaction,
|
||||
PrismaReplicaClient,
|
||||
PrismaTransactionClient,
|
||||
PrismaTransactionOptions,
|
||||
} from "@trigger.dev/database";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { env } from "./env.server";
|
||||
import { logger } from "./services/logger.server";
|
||||
import { isValidDatabaseUrl } from "./utils/db";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { $transaction as transac } from "@trigger.dev/database";
|
||||
|
||||
export type PrismaTransactionClient = Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
|
||||
>;
|
||||
|
||||
export type PrismaClientOrTransaction = PrismaClient | PrismaTransactionClient;
|
||||
|
||||
function isTransactionClient(prisma: PrismaClientOrTransaction): prisma is PrismaTransactionClient {
|
||||
return !("$transaction" in prisma);
|
||||
}
|
||||
|
||||
function isPrismaKnownError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||
return (
|
||||
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export type PrismaTransactionOptions = {
|
||||
/** The maximum amount of time (in ms) Prisma Client will wait to acquire a transaction from the database. The default value is 2000ms. */
|
||||
maxWait?: number;
|
||||
|
||||
/** The maximum amount of time (in ms) the interactive transaction can run before being canceled and rolled back. The default value is 5000ms. */
|
||||
timeout?: number;
|
||||
|
||||
/** Sets the transaction isolation level. By default this is set to the value currently configured in your database. */
|
||||
isolationLevel?: Prisma.TransactionIsolationLevel;
|
||||
|
||||
swallowPrismaErrors?: boolean;
|
||||
export type {
|
||||
PrismaTransactionClient,
|
||||
PrismaClientOrTransaction,
|
||||
PrismaTransactionOptions,
|
||||
PrismaReplicaClient,
|
||||
};
|
||||
|
||||
export async function $transaction<R>(
|
||||
@@ -41,14 +26,10 @@ export async function $transaction<R>(
|
||||
fn: (prisma: PrismaTransactionClient) => Promise<R>,
|
||||
options?: PrismaTransactionOptions
|
||||
): Promise<R | undefined> {
|
||||
if (isTransactionClient(prisma)) {
|
||||
return fn(prisma);
|
||||
}
|
||||
|
||||
try {
|
||||
return await (prisma as PrismaClient).$transaction(fn, options);
|
||||
} catch (error) {
|
||||
if (isPrismaKnownError(error)) {
|
||||
return transac(
|
||||
prisma,
|
||||
fn,
|
||||
(error) => {
|
||||
logger.error("prisma.$transaction error", {
|
||||
code: error.code,
|
||||
meta: error.meta,
|
||||
@@ -56,22 +37,15 @@ export async function $transaction<R>(
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
});
|
||||
|
||||
if (options?.swallowPrismaErrors) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
export { Prisma };
|
||||
|
||||
export const prisma = singleton("prisma", getClient);
|
||||
|
||||
export type PrismaReplicaClient = Omit<PrismaClient, "$transaction">;
|
||||
|
||||
export const $replica: PrismaReplicaClient = singleton(
|
||||
"replica",
|
||||
() => getReplicaClient() ?? prisma
|
||||
|
||||
@@ -31,7 +31,7 @@ const EnvironmentSchema = z.object({
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
ELECTRIC_ORIGIN: z.string(),
|
||||
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
|
||||
APP_ENV: z.string().default(process.env.NODE_ENV),
|
||||
SERVICE_NAME: z.string().default("trigger.dev webapp"),
|
||||
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
|
||||
@@ -103,6 +103,25 @@ const EnvironmentSchema = z.object({
|
||||
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
|
||||
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||||
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||||
API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||||
|
||||
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
|
||||
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
|
||||
|
||||
//Realtime rate limiting
|
||||
/**
|
||||
* @example "60s"
|
||||
* @example "1m"
|
||||
* @example "1h"
|
||||
* @example "1d"
|
||||
* @example "1000ms"
|
||||
* @example "1000s"
|
||||
*/
|
||||
REALTIME_RATE_LIMIT_WINDOW: z.string().default("1m"),
|
||||
REALTIME_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100),
|
||||
REALTIME_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||||
REALTIME_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||||
REALTIME_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
|
||||
|
||||
//Ingesting event rate limit
|
||||
INGEST_EVENT_RATE_LIMIT_WINDOW: z.string().default("60s"),
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
TaskRunFailedExecutionResult,
|
||||
TaskRunSuccessfulExecutionResult,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
|
||||
|
||||
import type {
|
||||
TaskRun,
|
||||
@@ -62,7 +62,7 @@ export function executionResultForTaskRun(
|
||||
id: taskRun.friendlyId,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "TASK_RUN_CANCELLED",
|
||||
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function executionResultForTaskRun(
|
||||
id: taskRun.friendlyId,
|
||||
error: {
|
||||
type: "INTERNAL_ERROR",
|
||||
code: "CONFIGURED_INCORRECTLY",
|
||||
code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY,
|
||||
},
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
export const MAX_TAGS_PER_RUN = 5;
|
||||
export const MAX_TAGS_PER_RUN = 10;
|
||||
|
||||
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
|
||||
if (tag.trim().length === 0) return;
|
||||
|
||||
@@ -62,8 +62,7 @@ type CommonRelatedRun = Prisma.Result<
|
||||
export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment,
|
||||
showSecretDetails: boolean
|
||||
env: AuthenticatedEnvironment
|
||||
): Promise<RetrieveRunResponse | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._replica.taskRun.findFirst({
|
||||
@@ -72,11 +71,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
attempts: true,
|
||||
lockedToVersion: true,
|
||||
schedule: true,
|
||||
tags: true,
|
||||
@@ -111,50 +106,48 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
let $output: any;
|
||||
let $outputPresignedUrl: string | undefined;
|
||||
|
||||
if (showSecretDetails) {
|
||||
const payloadPacket = await conditionallyImportPacket({
|
||||
data: taskRun.payload,
|
||||
dataType: taskRun.payloadType,
|
||||
});
|
||||
const payloadPacket = await conditionallyImportPacket({
|
||||
data: taskRun.payload,
|
||||
dataType: taskRun.payloadType,
|
||||
});
|
||||
|
||||
if (
|
||||
payloadPacket.dataType === "application/store" &&
|
||||
typeof payloadPacket.data === "string"
|
||||
) {
|
||||
$payloadPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
payloadPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$payload = await parsePacket(payloadPacket);
|
||||
}
|
||||
if (
|
||||
payloadPacket.dataType === "application/store" &&
|
||||
typeof payloadPacket.data === "string"
|
||||
) {
|
||||
$payloadPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
payloadPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$payload = await parsePacket(payloadPacket);
|
||||
}
|
||||
|
||||
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
|
||||
const completedAttempt = taskRun.attempts.find(
|
||||
(a) => a.status === "COMPLETED" && typeof a.output !== null
|
||||
);
|
||||
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
|
||||
const completedAttempt = taskRun.attempts.find(
|
||||
(a) => a.status === "COMPLETED" && typeof a.output !== null
|
||||
);
|
||||
|
||||
if (completedAttempt && completedAttempt.output) {
|
||||
const outputPacket = await conditionallyImportPacket({
|
||||
data: completedAttempt.output,
|
||||
dataType: completedAttempt.outputType,
|
||||
});
|
||||
if (completedAttempt && completedAttempt.output) {
|
||||
const outputPacket = await conditionallyImportPacket({
|
||||
data: completedAttempt.output,
|
||||
dataType: completedAttempt.outputType,
|
||||
});
|
||||
|
||||
if (
|
||||
outputPacket.dataType === "application/store" &&
|
||||
typeof outputPacket.data === "string"
|
||||
) {
|
||||
$outputPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
outputPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$output = await parsePacket(outputPacket);
|
||||
}
|
||||
if (
|
||||
outputPacket.dataType === "application/store" &&
|
||||
typeof outputPacket.data === "string"
|
||||
) {
|
||||
$outputPresignedUrl = await generatePresignedUrl(
|
||||
env.project.externalRef,
|
||||
env.slug,
|
||||
outputPacket.data,
|
||||
"GET"
|
||||
);
|
||||
} else {
|
||||
$output = await parsePacket(outputPacket);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +158,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
payloadPresignedUrl: $payloadPresignedUrl,
|
||||
output: $output,
|
||||
outputPresignedUrl: $outputPresignedUrl,
|
||||
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
|
||||
schedule: taskRun.schedule
|
||||
? {
|
||||
id: taskRun.schedule.friendlyId,
|
||||
@@ -179,17 +173,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
attempts: !showSecretDetails
|
||||
? []
|
||||
: taskRun.attempts.map((a) => ({
|
||||
id: a.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
|
||||
createdAt: a.createdAt ?? undefined,
|
||||
updatedAt: a.updatedAt ?? undefined,
|
||||
startedAt: a.startedAt ?? undefined,
|
||||
completedAt: a.completedAt ?? undefined,
|
||||
error: ApiRetrieveRunPresenter.apiErrorFromError(a.error),
|
||||
})),
|
||||
// We're removing attempts from the API
|
||||
attemptCount: taskRun.attempts.length,
|
||||
attempts: [],
|
||||
relatedRuns: {
|
||||
root: taskRun.rootTaskRun
|
||||
? await createCommonRunStructure(taskRun.rootTaskRun)
|
||||
|
||||
@@ -29,7 +29,7 @@ const CoercedDate = z.preprocess((arg) => {
|
||||
return arg;
|
||||
}, z.date().optional());
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
export const ApiRunListSearchParams = z.object({
|
||||
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
|
||||
"page[after]": z.string().optional(),
|
||||
"page[before]": z.string().optional(),
|
||||
@@ -121,45 +121,31 @@ const SearchParamsSchema = z.object({
|
||||
"filter[createdAt][period]": z.string().optional(),
|
||||
});
|
||||
|
||||
type SearchParamsSchema = z.infer<typeof SearchParamsSchema>;
|
||||
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
|
||||
|
||||
export class ApiRunListPresenter extends BasePresenter {
|
||||
public async call(
|
||||
project: Project,
|
||||
searchParams: URLSearchParams,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
environment?: RuntimeEnvironment
|
||||
): Promise<ListRunResponse> {
|
||||
return this.trace("call", async (span) => {
|
||||
const rawSearchParams = Object.fromEntries(searchParams.entries());
|
||||
const $searchParams = SearchParamsSchema.safeParse(rawSearchParams);
|
||||
|
||||
if (!$searchParams.success) {
|
||||
logger.error("Invalid search params", {
|
||||
searchParams: rawSearchParams,
|
||||
errors: $searchParams.error.errors,
|
||||
});
|
||||
|
||||
throw fromZodError($searchParams.error);
|
||||
}
|
||||
|
||||
logger.debug("Valid search params", { searchParams: $searchParams.data });
|
||||
|
||||
const options: RunListOptions = {
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// pagination
|
||||
if ($searchParams.data["page[size]"]) {
|
||||
options.pageSize = $searchParams.data["page[size]"];
|
||||
if (searchParams["page[size]"]) {
|
||||
options.pageSize = searchParams["page[size]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["page[after]"]) {
|
||||
options.cursor = $searchParams.data["page[after]"];
|
||||
if (searchParams["page[after]"]) {
|
||||
options.cursor = searchParams["page[after]"];
|
||||
options.direction = "forward";
|
||||
}
|
||||
|
||||
if ($searchParams.data["page[before]"]) {
|
||||
options.cursor = $searchParams.data["page[before]"];
|
||||
if (searchParams["page[before]"]) {
|
||||
options.cursor = searchParams["page[before]"];
|
||||
options.direction = "backward";
|
||||
}
|
||||
|
||||
@@ -167,12 +153,12 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
if (environment) {
|
||||
options.environments = [environment.id];
|
||||
} else {
|
||||
if ($searchParams.data["filter[env]"]) {
|
||||
if (searchParams["filter[env]"]) {
|
||||
const environments = await this._prisma.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
slug: {
|
||||
in: $searchParams.data["filter[env]"],
|
||||
in: searchParams["filter[env]"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -181,46 +167,46 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[status]"]) {
|
||||
options.statuses = $searchParams.data["filter[status]"].flatMap((status) =>
|
||||
if (searchParams["filter[status]"]) {
|
||||
options.statuses = searchParams["filter[status]"].flatMap((status) =>
|
||||
ApiRunListPresenter.apiStatusToRunStatuses(status)
|
||||
);
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[taskIdentifier]"]) {
|
||||
options.tasks = $searchParams.data["filter[taskIdentifier]"];
|
||||
if (searchParams["filter[taskIdentifier]"]) {
|
||||
options.tasks = searchParams["filter[taskIdentifier]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[version]"]) {
|
||||
options.versions = $searchParams.data["filter[version]"];
|
||||
if (searchParams["filter[version]"]) {
|
||||
options.versions = searchParams["filter[version]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[tag]"]) {
|
||||
options.tags = $searchParams.data["filter[tag]"];
|
||||
if (searchParams["filter[tag]"]) {
|
||||
options.tags = searchParams["filter[tag]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[bulkAction]"]) {
|
||||
options.bulkId = $searchParams.data["filter[bulkAction]"];
|
||||
if (searchParams["filter[bulkAction]"]) {
|
||||
options.bulkId = searchParams["filter[bulkAction]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[schedule]"]) {
|
||||
options.scheduleId = $searchParams.data["filter[schedule]"];
|
||||
if (searchParams["filter[schedule]"]) {
|
||||
options.scheduleId = searchParams["filter[schedule]"];
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][from]"]) {
|
||||
options.from = $searchParams.data["filter[createdAt][from]"].getTime();
|
||||
if (searchParams["filter[createdAt][from]"]) {
|
||||
options.from = searchParams["filter[createdAt][from]"].getTime();
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][to]"]) {
|
||||
options.to = $searchParams.data["filter[createdAt][to]"].getTime();
|
||||
if (searchParams["filter[createdAt][to]"]) {
|
||||
options.to = searchParams["filter[createdAt][to]"].getTime();
|
||||
}
|
||||
|
||||
if ($searchParams.data["filter[createdAt][period]"]) {
|
||||
options.period = $searchParams.data["filter[createdAt][period]"];
|
||||
if (searchParams["filter[createdAt][period]"]) {
|
||||
options.period = searchParams["filter[createdAt][period]"];
|
||||
}
|
||||
|
||||
if (typeof $searchParams.data["filter[isTest]"] === "boolean") {
|
||||
options.isTest = $searchParams.data["filter[isTest]"];
|
||||
if (typeof searchParams["filter[isTest]"] === "boolean") {
|
||||
options.isTest = searchParams["filter[isTest]"];
|
||||
}
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
|
||||
@@ -21,11 +21,13 @@ export class RunPresenter {
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
runFriendlyId,
|
||||
showDeletedLogs,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
runFriendlyId: string;
|
||||
showDeletedLogs: boolean;
|
||||
}) {
|
||||
const run = await this.#prismaClient.taskRun.findFirstOrThrow({
|
||||
select: {
|
||||
@@ -72,31 +74,41 @@ export class RunPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const showLogs = showDeletedLogs || !run.logsDeletedAt;
|
||||
|
||||
const runData = {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
status: run.status,
|
||||
isFinished: isFinalRunStatus(run.status),
|
||||
completedAt: run.completedAt,
|
||||
logsDeletedAt: showDeletedLogs ? null : run.logsDeletedAt,
|
||||
rootTaskRun: run.rootTaskRun,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
};
|
||||
|
||||
if (!showLogs) {
|
||||
return {
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// get the events
|
||||
const traceSummary = await eventRepository.getTraceSummary(run.traceId);
|
||||
|
||||
if (!traceSummary) {
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
status: run.status,
|
||||
isFinished: isFinalRunStatus(run.status),
|
||||
completedAt: run.completedAt,
|
||||
logsDeletedAt: run.logsDeletedAt,
|
||||
rootTaskRun: run.rootTaskRun,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
},
|
||||
run: runData,
|
||||
trace: undefined,
|
||||
};
|
||||
}
|
||||
@@ -139,26 +151,7 @@ export class RunPresenter {
|
||||
}
|
||||
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
status: run.status,
|
||||
isFinished: isFinalRunStatus(run.status),
|
||||
completedAt: run.completedAt,
|
||||
logsDeletedAt: run.logsDeletedAt,
|
||||
rootTaskRun: run.rootTaskRun,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
},
|
||||
run: runData,
|
||||
trace: {
|
||||
rootSpanStatus,
|
||||
events: events,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { machinePresetFromName } from "~/v3/machinePresets.server";
|
||||
import { FINAL_ATTEMPT_STATUSES, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { FINAL_ATTEMPT_STATUSES, isFailedRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { getMaxDuration } from "~/v3/utils/maxDuration";
|
||||
|
||||
@@ -294,6 +294,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
usageDurationMs: run.usageDurationMs,
|
||||
isFinished,
|
||||
isRunning: RUNNING_STATUSES.includes(run.status),
|
||||
isError: isFailedRunStatus(run.status),
|
||||
payload,
|
||||
payloadType: run.payloadType,
|
||||
output,
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Sheet, SheetBody, SheetContent, SheetHeader } from "~/components/primitives/Sheet";
|
||||
import { ClientEndpoint } from "~/presenters/EnvironmentsPresenter.server";
|
||||
import { endpointStreamingPath } from "~/utils/pathBuilder";
|
||||
import { EndpointIndexStatus, RuntimeEnvironmentType } from "../../../../../packages/database/src";
|
||||
import { EndpointIndexStatus, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { bodySchema } from "../resources.environments.$environmentParam.endpoint";
|
||||
|
||||
type ConfigureEndpointSheetProps = {
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import {
|
||||
projectEnvironmentsStreamingPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { RuntimeEnvironmentType } from "../../../../../packages/database/src";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { ConfigureEndpointSheet } from "./ConfigureEndpointSheet";
|
||||
import { FirstEndpointSheet } from "./FirstEndpointSheet";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
+11
-10
@@ -80,6 +80,7 @@ import {
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { getResizableSnapshot } from "~/services/resizablePanel.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -115,12 +116,14 @@ type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new RunPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
showDeletedLogs: !!impersonationId,
|
||||
projectSlug: projectParam,
|
||||
runFriendlyId: runParam,
|
||||
});
|
||||
@@ -498,16 +501,14 @@ function TasksTreeView({
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-2">
|
||||
<SearchField onChange={setFilterText} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Errors only"
|
||||
checked={errorsOnly}
|
||||
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Errors only"
|
||||
checked={errorsOnly}
|
||||
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId}>
|
||||
{/* Tree list */}
|
||||
@@ -1005,7 +1006,7 @@ function ShowParentLink({
|
||||
{mouseOver ? (
|
||||
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
|
||||
) : (
|
||||
<ShowParentIcon className="text-charcoal-650 h-4 w-4" />
|
||||
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
|
||||
)}
|
||||
<Paragraph
|
||||
variant="small"
|
||||
|
||||
+8
-10
@@ -528,16 +528,14 @@ function TasksTreeView({
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3">
|
||||
<SearchField onChange={setFilterText} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Errors only"
|
||||
checked={errorsOnly}
|
||||
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Errors only"
|
||||
checked={errorsOnly}
|
||||
onCheckedChange={(e) => setErrorsOnly(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<ResizablePanelGroup autosaveId={resizableSettings.tree.autosaveId}>
|
||||
{/* Tree list */}
|
||||
@@ -1015,7 +1013,7 @@ function ShowParentLink({ runFriendlyId }: { runFriendlyId: string }) {
|
||||
{mouseOver ? (
|
||||
<ShowParentIconSelected className="h-4 w-4 text-indigo-500" />
|
||||
) : (
|
||||
<ShowParentIcon className="text-charcoal-650 h-4 w-4" />
|
||||
<ShowParentIcon className="h-4 w-4 text-charcoal-650" />
|
||||
)}
|
||||
<Paragraph
|
||||
variant="small"
|
||||
|
||||
@@ -117,6 +117,7 @@ export default function ChoosePlanPage() {
|
||||
subscription={v3Subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan={false}
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,12 @@ import { z } from "zod";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { MainBody } from "~/components/layout/AppLayout";
|
||||
import { SideMenu } from "~/components/navigation/SideMenu";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { getCurrentPlan, getUsage } from "~/services/platform.v3.server";
|
||||
import { getCachedUsage, getCurrentPlan, getUsage } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { organizationPath } from "~/utils/pathBuilder";
|
||||
@@ -30,6 +29,27 @@ export function useCurrentPlan(matches?: UIMatch[]) {
|
||||
return data?.currentPlan;
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
|
||||
const { currentParams, nextParams } = params;
|
||||
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// This prevents revalidation when there are search params changes
|
||||
// IMPORTANT: If the loader function depends on search params, this should be updated
|
||||
return params.currentUrl.pathname !== params.nextUrl.pathname;
|
||||
};
|
||||
|
||||
// IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
@@ -51,11 +71,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const firstDayOfMonth = new Date();
|
||||
firstDayOfMonth.setUTCDate(1);
|
||||
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setUTCDate(tomorrow.getDate() + 1);
|
||||
|
||||
// Using the 1st day of next month means we get the usage for the current month
|
||||
// and the cache key for getCachedUsage is stable over the month
|
||||
const firstDayOfNextMonth = new Date();
|
||||
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
|
||||
firstDayOfNextMonth.setUTCDate(1);
|
||||
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [plan, usage] = await Promise.all([
|
||||
getCurrentPlan(organization.id),
|
||||
getUsage(organization.id, { from: firstDayOfMonth, to: tomorrow }),
|
||||
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
|
||||
]);
|
||||
|
||||
let hasExceededFreeTier = false;
|
||||
@@ -101,26 +127,6 @@ export function ErrorBoundary() {
|
||||
return org ? (
|
||||
<RouteErrorDisplay button={{ title: org.title, to: organizationPath(org) }} />
|
||||
) : (
|
||||
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
|
||||
<RouteErrorDisplay button={{ title: "Go to homepage", to: "/" }} />
|
||||
);
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
defaultShouldRevalidate,
|
||||
currentParams,
|
||||
nextParams,
|
||||
}) => {
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
@@ -37,11 +37,15 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
|
||||
const currentPlan = await getCurrentPlan(organization.id);
|
||||
|
||||
return typedjson({ ...plans, ...currentPlan, organizationSlug });
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
|
||||
return typedjson({ ...plans, ...currentPlan, organizationSlug, periodEnd });
|
||||
}
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { plans, v3Subscription, organizationSlug } = useTypedLoaderData<typeof loader>();
|
||||
const { plans, v3Subscription, organizationSlug, periodEnd } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="flex max-w-[80rem] flex-col items-center gap-8 p-3">
|
||||
@@ -52,6 +56,7 @@ export default function ChoosePlanPage() {
|
||||
organizationSlug={organizationSlug}
|
||||
hasPromotedPlan
|
||||
showGithubVerificationBadge
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
@@ -17,21 +18,25 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import { TextArea } from "~/components/primitives/TextArea";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { sendToPlain } from "~/utils/plain.server";
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
companySize: z.string().optional(),
|
||||
whyUseUs: z.string().optional(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const presenter = new NewOrganizationPresenter();
|
||||
const { hasOrganizations } = await presenter.call({ userId });
|
||||
const { hasOrganizations } = await presenter.call({ userId: userId });
|
||||
|
||||
return typedjson({
|
||||
hasOrganizations,
|
||||
@@ -39,8 +44,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const user = await requireUser(request);
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
@@ -51,10 +55,41 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
try {
|
||||
const organization = await createOrganization({
|
||||
title: submission.value.orgName,
|
||||
userId,
|
||||
userId: user.id,
|
||||
companySize: submission.value.companySize ?? null,
|
||||
});
|
||||
|
||||
const whyUseUs = formData.get("whyUseUs");
|
||||
|
||||
if (whyUseUs) {
|
||||
try {
|
||||
await sendToPlain({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? user.displayName ?? user.email,
|
||||
title: "New org feedback",
|
||||
components: [
|
||||
uiComponent.text({
|
||||
text: `${submission.value.orgName} just created a new organization.`,
|
||||
}),
|
||||
uiComponent.divider({ spacingSize: "M" }),
|
||||
uiComponent.text({
|
||||
size: "L",
|
||||
color: "NORMAL",
|
||||
text: "What problem are you trying to solve?",
|
||||
}),
|
||||
uiComponent.text({
|
||||
size: "L",
|
||||
color: "NORMAL",
|
||||
text: whyUseUs.toString(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error sending data to Plain when creating an org:", { error });
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(organizationPath(organization));
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
@@ -97,39 +132,48 @@ export default function NewOrganizationPage() {
|
||||
<FormError id={orgName.errorId}>{orgName.error}</FormError>
|
||||
</InputGroup>
|
||||
{isManagedCloud && (
|
||||
<InputGroup>
|
||||
<Label htmlFor={"companySize"}>Number of employees</Label>
|
||||
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
|
||||
<RadioGroupItem
|
||||
id="employees-1-5"
|
||||
label="1-5"
|
||||
value={"1-5"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-6-49"
|
||||
label="6-49"
|
||||
value={"6-49"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-50-99"
|
||||
label="50-99"
|
||||
value={"50-99"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-100+"
|
||||
label="100+"
|
||||
value={"100+"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
<>
|
||||
<InputGroup>
|
||||
<Label htmlFor={"companySize"}>Number of employees</Label>
|
||||
<RadioGroup name="companySize" className="flex items-center justify-between gap-2">
|
||||
<RadioGroupItem
|
||||
id="employees-1-5"
|
||||
label="1-5"
|
||||
value={"1-5"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-6-49"
|
||||
label="6-49"
|
||||
value={"6-49"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-50-99"
|
||||
label="50-99"
|
||||
value={"50-99"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="employees-100+"
|
||||
label="100+"
|
||||
value={"100+"}
|
||||
variant="button/small"
|
||||
className="grow"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={"whyUseUs"}>What problem are you trying to solve?</Label>
|
||||
<TextArea name="whyUseUs" rows={4} spellCheck={false} />
|
||||
<Hint>
|
||||
Your answer will help us understand your use case and provide better support.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
|
||||
export async function action({ request }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const claims = {
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
};
|
||||
|
||||
return json(claims);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { z } from "zod";
|
||||
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
|
||||
|
||||
const RequestBodySchema = z.object({
|
||||
claims: z
|
||||
.object({
|
||||
scopes: z.array(z.string()).default([]),
|
||||
})
|
||||
.optional(),
|
||||
expirationTime: z.union([z.number(), z.string()]).optional(),
|
||||
});
|
||||
|
||||
export async function action({ request }: LoaderFunctionArgs) {
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedBody = RequestBodySchema.safeParse(await request.json());
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return json(
|
||||
{ error: "Invalid request body", issues: parsedBody.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const claims = {
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
...parsedBody.data.claims,
|
||||
};
|
||||
|
||||
const jwt = await internal_generateJWT({
|
||||
secretKey: authenticationResult.apiKey,
|
||||
payload: claims,
|
||||
expirationTime: parsedBody.data.expirationTime ?? "1h",
|
||||
});
|
||||
|
||||
return json({ token: jwt });
|
||||
}
|
||||
+1
-1
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
id: parsedParams.data.connectionId,
|
||||
integration: {
|
||||
slug: parsedParams.data.integrationSlug,
|
||||
organization: authenticatedEnv.organization,
|
||||
organizationId: authenticatedEnv.organization.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
|
||||
@@ -1,62 +1,36 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ValidationError } from "zod-validation-error";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import {
|
||||
ApiRunListPresenter,
|
||||
ApiRunListSearchParams,
|
||||
} from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
export const loader = createLoaderPATApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
corsStrategy: "all",
|
||||
},
|
||||
async ({ searchParams, params, authentication }) => {
|
||||
const project = await findProjectByRef(params.projectRef, authentication.userId);
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const $params = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!$params.success) {
|
||||
return json({ error: "Invalid params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectByRef($params.data.projectRef, authenticationResult.userId);
|
||||
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const presenter = new ApiRunListPresenter();
|
||||
|
||||
try {
|
||||
const result = await presenter.call(project, url.searchParams);
|
||||
const presenter = new ApiRunListPresenter();
|
||||
const result = await presenter.call(project, searchParams);
|
||||
|
||||
if (!result) {
|
||||
return apiCors(request, json({ data: [] }));
|
||||
return json({ data: [] });
|
||||
}
|
||||
|
||||
return apiCors(request, json(result));
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Query Error", details: error.details }, { status: 400 })
|
||||
);
|
||||
} else {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
);
|
||||
}
|
||||
return json(result);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -89,6 +89,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
tags: {
|
||||
connect: tagIds.map((id) => ({ id })),
|
||||
},
|
||||
runTags: {
|
||||
push: newTags,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const service = new CreateTaskRunAttemptService();
|
||||
|
||||
try {
|
||||
const { execution } = await service.call(runParam, authenticationResult.environment);
|
||||
const { execution } = await service.call({
|
||||
runId: runParam,
|
||||
authenticatedEnv: authenticationResult.environment,
|
||||
});
|
||||
|
||||
return json(execution, { status: 200 });
|
||||
} catch (error) {
|
||||
|
||||
@@ -62,11 +62,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(
|
||||
updatedRun.friendlyId,
|
||||
authenticationResult.environment,
|
||||
true
|
||||
);
|
||||
const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
|
||||
@@ -1,52 +1,29 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { ValidationError } from "zod-validation-error";
|
||||
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import {
|
||||
ApiRunListPresenter,
|
||||
ApiRunListSearchParams,
|
||||
} from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, {
|
||||
allowPublicKey: false,
|
||||
});
|
||||
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
const presenter = new ApiRunListPresenter();
|
||||
|
||||
try {
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
searchParams: ApiRunListSearchParams,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ searchParams, authentication }) => {
|
||||
const presenter = new ApiRunListPresenter();
|
||||
const result = await presenter.call(
|
||||
authenticatedEnv.project,
|
||||
url.searchParams,
|
||||
authenticatedEnv
|
||||
authentication.environment.project,
|
||||
searchParams,
|
||||
authentication.environment
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return apiCors(request, json({ data: [] }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(result));
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Query Error", details: error.details }, { status: 400 })
|
||||
);
|
||||
} else {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
|
||||
);
|
||||
}
|
||||
return json(result);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -104,10 +104,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
batchId: result.batch.friendlyId,
|
||||
runs: result.runs,
|
||||
});
|
||||
return json(
|
||||
{
|
||||
batchId: result.batch.friendlyId,
|
||||
runs: result.runs,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-trigger-jwt-claims": JSON.stringify({
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
|
||||
@@ -30,6 +30,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request);
|
||||
|
||||
@@ -105,9 +107,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
id: run.friendlyId,
|
||||
});
|
||||
return json(
|
||||
{
|
||||
id: run.friendlyId,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"x-trigger-jwt-claims": JSON.stringify({
|
||||
sub: authenticationResult.environment.id,
|
||||
pub: true,
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
|
||||
@@ -1,44 +1,31 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ runs: params.runId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication }) => {
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(params.runId, authentication.environment);
|
||||
|
||||
if (!result) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
|
||||
}
|
||||
|
||||
const { runId } = parsed.data;
|
||||
|
||||
const showSecretDetails = authenticationResult.type === "PRIVATE";
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(runId, authenticatedEnv, showSecretDetails);
|
||||
|
||||
if (!result) {
|
||||
return apiCors(request, json({ error: "Run not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(result));
|
||||
}
|
||||
);
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function Page() {
|
||||
<Label htmlFor={confirmEmail.id}>How did you hear about us?</Label>
|
||||
<Input
|
||||
{...conform.input(referralSource, { type: "text" })}
|
||||
placeholder="Google, Twitter…?"
|
||||
placeholder="Google, X (Twitter)…?"
|
||||
icon="heart"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ batch: params.batchId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication, request }) => {
|
||||
const batchRun = await $replica.batchTaskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.batchId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!batchRun) {
|
||||
return json({ error: "Batch not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return realtimeClient.streamBatch(request.url, authentication.environment, batchRun.id);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (params) => ({ runs: params.runId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, authentication, request }) => {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Run not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return realtimeClient.streamRun(request.url, authentication.environment, run.id);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
tags: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
return value ? value.split(",") : undefined;
|
||||
}),
|
||||
});
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
searchParams: SearchParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (_, searchParams) => searchParams,
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ searchParams, authentication, request }) => {
|
||||
return realtimeClient.streamRuns(request.url, authentication.environment, searchParams);
|
||||
}
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { sendToPlain } from "~/utils/plain.server";
|
||||
|
||||
let client: PlainClient | undefined;
|
||||
|
||||
@@ -14,7 +15,7 @@ export const feedbackTypeLabel = {
|
||||
feature: "Feature request",
|
||||
help: "Help me out",
|
||||
enterprise: "Enterprise enquiry",
|
||||
"developer preview": "Developer preview feedback",
|
||||
feedback: "General feedback",
|
||||
};
|
||||
|
||||
export type FeedbackType = keyof typeof feedbackTypeLabel;
|
||||
@@ -32,7 +33,7 @@ const feedbackType = z.union(
|
||||
export const schema = z.object({
|
||||
path: z.string(),
|
||||
feedbackType,
|
||||
message: z.string().min(1, "Must be at least 1 character"),
|
||||
message: z.string().min(10, "Must be at least 10 characters"),
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
@@ -45,60 +46,12 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
|
||||
try {
|
||||
if (!env.PLAIN_API_KEY) {
|
||||
console.error("PLAIN_API_KEY is not set");
|
||||
submission.error.message = "PLAIN_API_KEY is not set";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
client = new PlainClient({
|
||||
apiKey: env.PLAIN_API_KEY,
|
||||
});
|
||||
|
||||
const upsertCustomerRes = await client.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email,
|
||||
},
|
||||
onCreate: {
|
||||
externalId: user.id,
|
||||
fullName: user.name ?? "",
|
||||
// TODO - Optional: set 'first name' on user
|
||||
// shortName: ''
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
onUpdate: {
|
||||
externalId: { value: user.id },
|
||||
fullName: { value: user.name ?? "" },
|
||||
// TODO - see above
|
||||
// shortName: { value: "" },
|
||||
email: {
|
||||
email: user.email,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (upsertCustomerRes.error) {
|
||||
console.error(
|
||||
inspect(upsertCustomerRes.error, {
|
||||
showHidden: false,
|
||||
depth: null,
|
||||
colors: true,
|
||||
})
|
||||
);
|
||||
submission.error.message = upsertCustomerRes.error.message;
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const title = feedbackTypeLabel[submission.value.feedbackType as FeedbackType];
|
||||
const createThreadRes = await client.createThread({
|
||||
customerIdentifier: {
|
||||
customerId: upsertCustomerRes.data.customer.id,
|
||||
},
|
||||
await sendToPlain({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? user.displayName ?? user.email,
|
||||
title,
|
||||
components: [
|
||||
uiComponent.text({
|
||||
@@ -123,31 +76,15 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
text: submission.value.message,
|
||||
}),
|
||||
],
|
||||
// TODO: Optional: set labels on threads here on creation
|
||||
// labelTypeIds: [],
|
||||
|
||||
// TODO: Optional: set the priority (0 is urgent, 3 is low)
|
||||
// priority: 0,
|
||||
});
|
||||
|
||||
if (createThreadRes.error) {
|
||||
console.error(
|
||||
inspect(createThreadRes.error, {
|
||||
showHidden: false,
|
||||
depth: null,
|
||||
colors: true,
|
||||
})
|
||||
);
|
||||
submission.error.message = createThreadRes.error.message;
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
submission.value.path,
|
||||
request,
|
||||
"Thanks for your feedback! We'll get back to you soon."
|
||||
);
|
||||
} catch (e) {
|
||||
return json(e, { status: 400 });
|
||||
submission.error.message = e instanceof Error ? e.message : "Unknown error";
|
||||
return json(submission);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -44,6 +44,7 @@ import { CronPattern, UpsertSchedule } from "~/v3/schedules";
|
||||
import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.server";
|
||||
import { AIGeneratedCronField } from "../resources.orgs.$organizationSlug.projects.$projectParam.schedules.new.natural-language";
|
||||
import { TimezoneList } from "~/components/scheduled/timezones";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
const cronFormat = `* * * * *
|
||||
┬ ┬ ┬ ┬ ┬
|
||||
@@ -94,9 +95,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
submission.value?.friendlyId === result.id ? "Schedule updated" : "Schedule created"
|
||||
);
|
||||
} catch (error: any) {
|
||||
const errorMessage = `Failed: ${
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
}`;
|
||||
logger.error("Failed to create schedule", error);
|
||||
|
||||
const errorMessage = `Something went wrong. Please try again.`;
|
||||
return redirectWithErrorMessage(
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
|
||||
+42
-12
@@ -1,4 +1,10 @@
|
||||
import { CheckIcon, ClockIcon, CloudArrowDownIcon, QueueListIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CloudArrowDownIcon,
|
||||
EnvelopeIcon,
|
||||
QueueListIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
@@ -6,12 +12,14 @@ import {
|
||||
formatDurationMilliseconds,
|
||||
nanosecondsToMilliseconds,
|
||||
TaskRunError,
|
||||
taskRunErrorEnhancer,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { typedjson, useTypedFetcher } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
@@ -171,7 +179,7 @@ function SpanBody({
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
|
||||
<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}
|
||||
@@ -313,7 +321,7 @@ function SpanBody({
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Message</Property.Label>
|
||||
<Property.Value>{span.message}</Property.Value>
|
||||
<Property.Value className="whitespace-pre-wrap">{span.message}</Property.Value>
|
||||
</Property.Item>
|
||||
{span.triggeredRuns.length > 0 && (
|
||||
<Property.Item>
|
||||
@@ -412,7 +420,7 @@ function RunBody({
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden">
|
||||
<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"}
|
||||
@@ -849,7 +857,7 @@ function RunTimeline({ run }: { run: SpanRun }) {
|
||||
<RunTimelineEvent
|
||||
title="Finished"
|
||||
subtitle={<DateTimeAccurate date={run.updatedAt} />}
|
||||
state="complete"
|
||||
state={run.isError ? "error" : "complete"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -933,12 +941,14 @@ function RunTimelineLine({ title, state }: RunTimelineLineProps) {
|
||||
}
|
||||
|
||||
function RunError({ error }: { error: TaskRunError }) {
|
||||
switch (error.type) {
|
||||
const enhancedError = taskRunErrorEnhancer(error);
|
||||
|
||||
switch (enhancedError.type) {
|
||||
case "STRING_ERROR":
|
||||
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">Error</Header3>
|
||||
<Callout variant="error">{error.raw}</Callout>
|
||||
<Callout variant="error">{enhancedError.raw}</Callout>
|
||||
</div>
|
||||
);
|
||||
case "CUSTOM_ERROR": {
|
||||
@@ -947,7 +957,7 @@ function RunError({ error }: { error: TaskRunError }) {
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={error.raw}
|
||||
code={enhancedError.raw}
|
||||
maxLines={20}
|
||||
/>
|
||||
</div>
|
||||
@@ -955,16 +965,36 @@ function RunError({ error }: { error: TaskRunError }) {
|
||||
}
|
||||
case "BUILT_IN_ERROR":
|
||||
case "INTERNAL_ERROR": {
|
||||
const name = "name" in error ? error.name : error.code;
|
||||
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>
|
||||
{error.message && <Callout variant="error">{error.message}</Callout>}
|
||||
{error.stackTrace && (
|
||||
{enhancedError.message && <Callout variant="error">{enhancedError.message}</Callout>}
|
||||
{enhancedError.link &&
|
||||
(enhancedError.link.magic === "CONTACT_FORM" ? (
|
||||
<Feedback
|
||||
button={
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-400"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
{enhancedError.link.name}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Callout variant="docs" to={enhancedError.link.href}>
|
||||
{enhancedError.link.name}
|
||||
</Callout>
|
||||
))}
|
||||
{enhancedError.stackTrace && (
|
||||
<CodeBlock
|
||||
showCopyButton={false}
|
||||
showLineNumbers={false}
|
||||
code={error.stackTrace}
|
||||
code={enhancedError.stackTrace}
|
||||
maxLines={20}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ShieldCheckIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowDownCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Form, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import {
|
||||
FreePlanDefinition,
|
||||
Limits,
|
||||
@@ -14,11 +18,13 @@ import {
|
||||
SetPlanBody,
|
||||
SubscriptionResult,
|
||||
} from "@trigger.dev/platform/v3";
|
||||
import { GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { DefinitionTip } from "~/components/DefinitionTooltip";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,14 +32,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TextArea } from "~/components/primitives/TextArea";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { setPlan } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { sendToPlain } from "~/utils/plain.server";
|
||||
|
||||
const Params = z.object({
|
||||
organizationSlug: z.string(),
|
||||
@@ -43,6 +53,8 @@ const schema = z.object({
|
||||
type: z.enum(["free", "paid"]),
|
||||
planCode: z.string().optional(),
|
||||
callerPath: z.string(),
|
||||
reasons: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
@@ -51,11 +63,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
const { organizationSlug } = Params.parse(params);
|
||||
const user = await requireUser(request);
|
||||
const formData = await request.formData();
|
||||
const reasons = formData.getAll("reasons");
|
||||
const message = formData.get("message");
|
||||
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
const form = schema.parse(formData);
|
||||
const form = schema.parse({
|
||||
...Object.fromEntries(formData),
|
||||
reasons,
|
||||
message: message || undefined,
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug },
|
||||
@@ -69,9 +86,53 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
|
||||
switch (form.type) {
|
||||
case "free": {
|
||||
try {
|
||||
if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
|
||||
await sendToPlain({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? "",
|
||||
title: "Plan cancelation feedback",
|
||||
components: [
|
||||
uiComponent.text({
|
||||
text: `${user.name} (${user.email}) just canceled their plan.`,
|
||||
}),
|
||||
uiComponent.divider({ spacingSize: "M" }),
|
||||
...(reasons.length > 0
|
||||
? [
|
||||
uiComponent.spacer({ size: "L" }),
|
||||
uiComponent.text({
|
||||
size: "L",
|
||||
color: "NORMAL",
|
||||
text: "Reasons:",
|
||||
}),
|
||||
uiComponent.text({
|
||||
text: reasons.join(", "),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(message
|
||||
? [
|
||||
uiComponent.spacer({ size: "L" }),
|
||||
uiComponent.text({
|
||||
size: "L",
|
||||
color: "NORMAL",
|
||||
text: "Comment:",
|
||||
}),
|
||||
uiComponent.text({
|
||||
text: message.toString(),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
|
||||
}
|
||||
payload = {
|
||||
type: "free" as const,
|
||||
userId,
|
||||
userId: user.id,
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -82,10 +143,13 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
payload = {
|
||||
type: "paid" as const,
|
||||
planCode: form.planCode,
|
||||
userId,
|
||||
userId: user.id,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid form type");
|
||||
}
|
||||
}
|
||||
|
||||
return setPlan(organization, request, form.callerPath, payload);
|
||||
@@ -134,6 +198,7 @@ type PricingPlansProps = {
|
||||
organizationSlug: string;
|
||||
hasPromotedPlan: boolean;
|
||||
showGithubVerificationBadge?: boolean;
|
||||
periodEnd: Date;
|
||||
};
|
||||
|
||||
export function PricingPlans({
|
||||
@@ -142,6 +207,7 @@ export function PricingPlans({
|
||||
organizationSlug,
|
||||
hasPromotedPlan,
|
||||
showGithubVerificationBadge,
|
||||
periodEnd,
|
||||
}: PricingPlansProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@@ -151,6 +217,7 @@ export function PricingPlans({
|
||||
subscription={subscription}
|
||||
organizationSlug={organizationSlug}
|
||||
showGithubVerificationBadge={showGithubVerificationBadge}
|
||||
periodEnd={periodEnd}
|
||||
/>
|
||||
<TierHobby
|
||||
plan={plans.hobby}
|
||||
@@ -172,23 +239,33 @@ export function TierFree({
|
||||
subscription,
|
||||
organizationSlug,
|
||||
showGithubVerificationBadge,
|
||||
periodEnd,
|
||||
}: {
|
||||
plan: FreePlanDefinition;
|
||||
subscription?: SubscriptionResult;
|
||||
organizationSlug: string;
|
||||
showGithubVerificationBadge?: boolean;
|
||||
periodEnd: Date;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const navigation = useNavigation();
|
||||
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isLackingFeaturesChecked, setIsLackingFeaturesChecked] = useState(false);
|
||||
const status = subscription?.freeTierStatus ?? "requires_connect";
|
||||
|
||||
useEffect(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, [subscription]);
|
||||
|
||||
return (
|
||||
<TierContainer>
|
||||
<div className="relative">
|
||||
<PricingHeader title={plan.title} cost={0} />
|
||||
<TierLimit href="https://trigger.dev/pricing#computePricing">
|
||||
${plan.limits.includedUsage / 100} free monthly usage
|
||||
</TierLimit>
|
||||
{showGithubVerificationBadge && status === "approved" && (
|
||||
<SimpleTooltip
|
||||
buttonClassName="absolute right-1 top-1"
|
||||
@@ -242,16 +319,11 @@ export function TierFree({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Form action={formAction} method="post" id="subscribe">
|
||||
<input type="hidden" name="type" value="free" />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<TierLimit href="https://trigger.dev/pricing#computePricing">
|
||||
${plan.limits.includedUsage / 100} free usage
|
||||
</TierLimit>
|
||||
<div className="py-6">
|
||||
{status === "requires_connect" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<>
|
||||
{status === "requires_connect" ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div className="my-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/large"
|
||||
@@ -260,12 +332,16 @@ export function TierFree({
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? Spinner : undefined}
|
||||
>
|
||||
Unlock free plan
|
||||
Unlock Free plan
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form action={formAction} method="post" id="subscribe-free">
|
||||
<input type="hidden" name="type" value="free" />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<DialogHeader>Unlock the Free plan</DialogHeader>
|
||||
<div className="mb-3 mt-4 flex flex-col items-center gap-4 px-6">
|
||||
<div className="mb-5 mt-7 flex flex-col items-center gap-4 px-6">
|
||||
<GitHubLightIcon className="size-16" />
|
||||
<Paragraph variant="base/bright" className="text-center">
|
||||
To unlock the Free plan, we need to verify that you have an active GitHub
|
||||
@@ -282,16 +358,104 @@ export function TierFree({
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? Spinner : undefined}
|
||||
form="subscribe"
|
||||
form="subscribe-free"
|
||||
>
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : subscription?.plan !== undefined &&
|
||||
subscription.plan.type !== "free" &&
|
||||
subscription.canceledAt === undefined ? (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="cancel">
|
||||
<DialogTrigger asChild>
|
||||
<div className="my-6">
|
||||
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
|
||||
{`Downgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form action={formAction} method="post" id="subscribe">
|
||||
<input type="hidden" name="type" value="free" />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<DialogHeader>Downgrade plan?</DialogHeader>
|
||||
<div className="flex items-start gap-3 pb-6 pr-2 pt-8">
|
||||
<ArrowDownCircleIcon className="size-12 min-w-12 text-error" />
|
||||
<Paragraph variant="base/bright" className="text-text-bright">
|
||||
Are you sure you want to downgrade? You will lose access to your current
|
||||
plan's features on{" "}
|
||||
<DateTime
|
||||
includeTime={false}
|
||||
date={new Date(periodEnd.getTime() + 86400000)}
|
||||
/>
|
||||
.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Header2 className="mb-1">Why are you thinking of downgrading?</Header2>
|
||||
<ul className="space-y-1">
|
||||
{[
|
||||
"Subscription or usage costs too expensive",
|
||||
"Bugs or technical issues",
|
||||
"No longer need the service",
|
||||
"Found a better alternative",
|
||||
"Lacking features I need",
|
||||
].map((label, index) => (
|
||||
<li key={index}>
|
||||
<CheckboxWithLabel
|
||||
id={`reason-${index + 1}`}
|
||||
name="reasons"
|
||||
value={label}
|
||||
variant="simple"
|
||||
label={label}
|
||||
labelClassName="text-text-dimmed"
|
||||
onChange={(isChecked: boolean) => {
|
||||
if (label === "Lacking features I need") {
|
||||
setIsLackingFeaturesChecked(isChecked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<Header2 className="mb-1">
|
||||
{isLackingFeaturesChecked
|
||||
? "What features do you need? Or how can we improve?"
|
||||
: "What can we do to improve?"}
|
||||
</Header2>
|
||||
<TextArea id="improvement-suggestions" name="message" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
|
||||
type="submit"
|
||||
>
|
||||
Downgrade plan
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Form action={formAction} method="post" id="subscribe-verified" className="my-6">
|
||||
<input type="hidden" name="type" value="free" />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<Button
|
||||
variant="tertiary/large"
|
||||
type="submit"
|
||||
form="subscribe-verified"
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
disabled={
|
||||
@@ -305,12 +469,14 @@ export function TierFree({
|
||||
>
|
||||
{subscription?.plan === undefined
|
||||
? "Select plan"
|
||||
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
|
||||
: subscription.plan.type === "free"
|
||||
? "Current plan"
|
||||
: `Downgrade to ${plan.title}`}
|
||||
: subscription.canceledAt !== undefined
|
||||
? "Current plan"
|
||||
: "Select plan"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<ConcurrentRuns limits={plan.limits} />
|
||||
<FeatureItem checked>
|
||||
@@ -329,7 +495,7 @@ export function TierFree({
|
||||
<SupportLevel limits={plan.limits} />
|
||||
<Alerts limits={plan.limits} />
|
||||
</ul>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</TierContainer>
|
||||
);
|
||||
@@ -350,6 +516,11 @@ export function TierHobby({
|
||||
const navigation = useNavigation();
|
||||
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, [subscription]);
|
||||
|
||||
return (
|
||||
<TierContainer isHighlighted={isHighlighted}>
|
||||
@@ -357,15 +528,52 @@ export function TierHobby({
|
||||
<TierLimit href="https://trigger.dev/pricing#computePricing">
|
||||
${plan.limits.includedUsage / 100} usage included
|
||||
</TierLimit>
|
||||
<Form action={formAction} method="post" id="subscribe">
|
||||
<div className="py-6">
|
||||
<input type="hidden" name="type" value="paid" />
|
||||
<input type="hidden" name="planCode" value={plan.code} />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<Form action={formAction} method="post" id="subscribe-hobby" className="py-6">
|
||||
<input type="hidden" name="type" value="paid" />
|
||||
<input type="hidden" name="planCode" value={plan.code} />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
{subscription?.plan !== undefined &&
|
||||
subscription.plan.type !== "free" &&
|
||||
subscription.canceledAt === undefined &&
|
||||
subscription.plan.code !== plan.code ? (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="downgrade">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
|
||||
{`Downgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Downgrade plan?</DialogHeader>
|
||||
<div className="mb-2 mt-4 flex items-start gap-3">
|
||||
<span>
|
||||
<ArrowDownCircleIcon className="size-12 text-blue-500" />
|
||||
</span>
|
||||
<Paragraph variant="base/bright" className="text-text-bright">
|
||||
By downgrading you will lose access to your current plan's features and your
|
||||
included credits will be reduced.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="white" /> : undefined}
|
||||
form="subscribe-hobby"
|
||||
>
|
||||
{`Downgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant={isHighlighted ? "primary/large" : "tertiary/large"}
|
||||
fullWidth
|
||||
className="text-md font-medium"
|
||||
form="subscribe-hobby"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
|
||||
@@ -380,9 +588,9 @@ export function TierHobby({
|
||||
? `Upgrade to ${plan.title}`
|
||||
: subscription.plan.code === plan.code
|
||||
? "Current plan"
|
||||
: `Downgrade to ${plan.title}`}
|
||||
: `Upgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
<ConcurrentRuns limits={plan.limits} />
|
||||
@@ -425,7 +633,7 @@ export function TierPro({
|
||||
<TierLimit href="https://trigger.dev/pricing#computePricing">
|
||||
${plan.limits.includedUsage / 100} usage included
|
||||
</TierLimit>
|
||||
<Form action={formAction} method="post" id="subscribe">
|
||||
<Form action={formAction} method="post" id="subscribe-pro">
|
||||
<div className="py-6">
|
||||
<input type="hidden" name="type" value="paid" />
|
||||
<input type="hidden" name="planCode" value={plan.code} />
|
||||
@@ -433,6 +641,7 @@ export function TierPro({
|
||||
<Button
|
||||
variant="tertiary/large"
|
||||
fullWidth
|
||||
form="subscribe-pro"
|
||||
className="text-md font-medium"
|
||||
disabled={
|
||||
isLoading ||
|
||||
@@ -589,21 +798,28 @@ function PricingHeader({
|
||||
function TierLimit({ children, href }: { children: React.ReactNode; href?: string }) {
|
||||
return (
|
||||
<>
|
||||
<hr className="my-6 border-grid-bright" />
|
||||
{href ? (
|
||||
<div>
|
||||
<hr className="my-6 border-grid-bright" />
|
||||
<a
|
||||
href={href}
|
||||
className="hover:decoration-bright font-sans text-lg font-normal text-text-bright underline decoration-charcoal-500 underline-offset-4 transition"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</div>
|
||||
<SimpleTooltip
|
||||
buttonClassName="text-left w-fit"
|
||||
disableHoverableContent
|
||||
button={
|
||||
<a
|
||||
href={href}
|
||||
className="text-left font-sans text-lg font-normal text-text-bright underline decoration-charcoal-500 underline-offset-4 transition hover:decoration-text-bright"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
}
|
||||
content={
|
||||
<div className="flex items-center gap-1">
|
||||
<Paragraph variant="small">View compute pricing information</Paragraph>
|
||||
<ArrowUpRightIcon className="size-4 text-text-dimmed" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<hr className="my-6 border-grid-bright" />
|
||||
<div className="font-sans text-lg font-normal text-text-bright">{children}</div>
|
||||
</div>
|
||||
<div className="font-sans text-lg font-normal text-text-bright">{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { TextArea } from "~/components/primitives/TextArea";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex gap-16">
|
||||
<div>
|
||||
<div className="m-8 flex w-64 flex-col gap-4">
|
||||
<TextArea placeholder="6 rows (default)" autoFocus />
|
||||
<Input placeholder="Input" />
|
||||
<TextArea placeholder="3 rows" rows={3} />
|
||||
<TextArea disabled placeholder="Disabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,16 +126,20 @@ const stories: Story[] = [
|
||||
slug: "date-fields",
|
||||
},
|
||||
{
|
||||
name: "Simple form",
|
||||
slug: "simple-form",
|
||||
name: "Input fields",
|
||||
slug: "input-fields",
|
||||
},
|
||||
{
|
||||
name: "Search fields",
|
||||
slug: "search-fields",
|
||||
},
|
||||
{
|
||||
name: "Input fields",
|
||||
slug: "input-fields",
|
||||
name: "Simple form",
|
||||
slug: "simple-form",
|
||||
},
|
||||
{
|
||||
name: "Textarea",
|
||||
slug: "textarea",
|
||||
},
|
||||
{
|
||||
sectionTitle: "Menus",
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { Prettify } from "@trigger.dev/core";
|
||||
import { SignJWT, errors, jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import {
|
||||
RuntimeEnvironment,
|
||||
findEnvironmentByApiKey,
|
||||
findEnvironmentByPublicApiKey,
|
||||
} from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "./logger.server";
|
||||
import {
|
||||
PersonalAccessTokenAuthenticationResult,
|
||||
authenticateApiRequestWithPersonalAccessToken,
|
||||
isPersonalAccessToken,
|
||||
} from "./personalAccessToken.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import { SignJWT, jwtVerify, errors } from "jose";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
|
||||
|
||||
const ClaimsSchema = z.object({
|
||||
scopes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
|
||||
|
||||
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
|
||||
|
||||
export type AuthenticatedEnvironment = Optional<
|
||||
NonNullable<Awaited<ReturnType<typeof findEnvironmentByApiKey>>>,
|
||||
"orgMember"
|
||||
>;
|
||||
|
||||
type ApiAuthenticationResult = {
|
||||
export type ApiAuthenticationResult = {
|
||||
apiKey: string;
|
||||
type: "PUBLIC" | "PRIVATE";
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
environment: AuthenticatedEnvironment;
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
export async function authenticateApiRequest(
|
||||
request: Request,
|
||||
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult | undefined> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authenticateApiKey(apiKey, { allowPublicKey });
|
||||
return authenticateApiKey(apiKey, options);
|
||||
}
|
||||
|
||||
export async function authenticateApiKey(
|
||||
apiKey: string,
|
||||
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
|
||||
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult | undefined> {
|
||||
const result = getApiKeyResult(apiKey);
|
||||
|
||||
@@ -54,14 +58,12 @@ export async function authenticateApiKey(
|
||||
return;
|
||||
}
|
||||
|
||||
//if it's a public API key and we don't allow public keys, return
|
||||
if (!allowPublicKey) {
|
||||
const environment = await findEnvironmentByApiKey(result.apiKey);
|
||||
if (!environment) return;
|
||||
return {
|
||||
...result,
|
||||
environment,
|
||||
};
|
||||
if (!options.allowPublicKey && result.type === "PUBLIC") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.type) {
|
||||
@@ -81,27 +83,72 @@ export async function authenticateApiKey(
|
||||
environment,
|
||||
};
|
||||
}
|
||||
case "PUBLIC_JWT": {
|
||||
const validationResults = await validatePublicJwtKey(result.apiKey);
|
||||
|
||||
if (!validationResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
|
||||
|
||||
return {
|
||||
...result,
|
||||
environment: validationResults.environment,
|
||||
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateAuthorizationHeader(
|
||||
authorization: string,
|
||||
{
|
||||
allowPublicKey = false,
|
||||
allowJWT = false,
|
||||
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
|
||||
): Promise<ApiAuthenticationResult | undefined> {
|
||||
const apiKey = getApiKeyFromHeader(authorization);
|
||||
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
|
||||
}
|
||||
|
||||
export function isPublicApiKey(key: string) {
|
||||
return key.startsWith("pk_");
|
||||
}
|
||||
|
||||
export function getApiKeyFromRequest(request: Request) {
|
||||
const rawAuthorization = request.headers.get("Authorization");
|
||||
export function isSecretApiKey(key: string) {
|
||||
return key.startsWith("tr_");
|
||||
}
|
||||
|
||||
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
|
||||
if (!authorization.success) {
|
||||
export function getApiKeyFromRequest(request: Request) {
|
||||
return getApiKeyFromHeader(request.headers.get("Authorization"));
|
||||
}
|
||||
|
||||
export function getApiKeyFromHeader(authorization?: string | null) {
|
||||
if (typeof authorization !== "string" || !authorization) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = authorization.data.replace(/^Bearer /, "");
|
||||
const apiKey = authorization.replace(/^Bearer /, "");
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export function getApiKeyResult(apiKey: string) {
|
||||
const type = isPublicApiKey(apiKey) ? ("PUBLIC" as const) : ("PRIVATE" as const);
|
||||
export function getApiKeyResult(apiKey: string): {
|
||||
apiKey: string;
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
} {
|
||||
const type = isPublicApiKey(apiKey)
|
||||
? "PUBLIC"
|
||||
: isSecretApiKey(apiKey)
|
||||
? "PRIVATE"
|
||||
: isPublicJWT(apiKey)
|
||||
? "PUBLIC_JWT"
|
||||
: "PRIVATE"; // Fallback to private key
|
||||
return { apiKey, type };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +1,48 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
|
||||
import { RedisOptions } from "ioredis";
|
||||
import { createHash } from "node:crypto";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { Duration, Limiter, RateLimiter, createRedisRateLimitClient } from "./rateLimiter.server";
|
||||
|
||||
type Options = {
|
||||
redis?: RedisOptions;
|
||||
keyPrefix: string;
|
||||
pathMatchers: (RegExp | string)[];
|
||||
pathWhiteList?: (RegExp | string)[];
|
||||
limiter: Limiter;
|
||||
log?: {
|
||||
requests?: boolean;
|
||||
rejections?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
|
||||
export function authorizationRateLimitMiddleware({
|
||||
redis,
|
||||
keyPrefix,
|
||||
limiter,
|
||||
pathMatchers,
|
||||
pathWhiteList = [],
|
||||
log = {
|
||||
rejections: true,
|
||||
requests: true,
|
||||
},
|
||||
}: Options) {
|
||||
const rateLimiter = new RateLimiter({
|
||||
redis,
|
||||
keyPrefix,
|
||||
limiter,
|
||||
logSuccess: log.requests,
|
||||
logFailure: log.rejections,
|
||||
});
|
||||
|
||||
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
|
||||
}
|
||||
|
||||
// allow OPTIONS requests
|
||||
if (req.method.toUpperCase() === "OPTIONS") {
|
||||
return next();
|
||||
}
|
||||
|
||||
//first check if any of the pathMatchers match the request path
|
||||
const path = req.path;
|
||||
if (
|
||||
!pathMatchers.some((matcher) =>
|
||||
matcher instanceof RegExp ? matcher.test(path) : path === matcher
|
||||
)
|
||||
) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if the path matches any of the whitelisted paths
|
||||
if (
|
||||
pathWhiteList.some((matcher) =>
|
||||
matcher instanceof RegExp ? matcher.test(path) : path === matcher
|
||||
)
|
||||
) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
|
||||
}
|
||||
|
||||
const authorizationValue = req.headers.authorization;
|
||||
if (!authorizationValue) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
|
||||
}
|
||||
res.setHeader("Content-Type", "application/problem+json");
|
||||
return res.status(401).send(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: "Unauthorized",
|
||||
status: 401,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
|
||||
detail: "No authorization header provided",
|
||||
error: "No authorization header provided",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const hash = createHash("sha256");
|
||||
hash.update(authorizationValue);
|
||||
const hashedAuthorizationValue = hash.digest("hex");
|
||||
|
||||
const { success, pending, limit, reset, remaining } = await rateLimiter.limit(
|
||||
hashedAuthorizationValue
|
||||
);
|
||||
|
||||
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
|
||||
|
||||
res.set("x-ratelimit-limit", limit.toString());
|
||||
res.set("x-ratelimit-remaining", $remaining.toString());
|
||||
res.set("x-ratelimit-reset", reset.toString());
|
||||
|
||||
if (success) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/problem+json");
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
return res.status(429).send(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
limit,
|
||||
remaining,
|
||||
secondsUntilReset,
|
||||
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
import { authenticateAuthorizationHeader } from "./apiAuth.server";
|
||||
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
|
||||
import { Duration } from "./rateLimiter.server";
|
||||
|
||||
export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
redis: {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
keyPrefix: "api",
|
||||
limiter: Ratelimit.tokenBucket(
|
||||
env.API_RATE_LIMIT_REFILL_RATE,
|
||||
env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
env.API_RATE_LIMIT_MAX
|
||||
),
|
||||
defaultLimiter: {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.API_RATE_LIMIT_MAX,
|
||||
},
|
||||
limiterCache: {
|
||||
fresh: 60_000 * 10, // Data is fresh for 10 minutes
|
||||
stale: 60_000 * 20, // Date is stale after 20 minutes
|
||||
},
|
||||
limiterConfigOverride: async (authorizationValue) => {
|
||||
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
|
||||
allowPublicKey: true,
|
||||
allowJWT: true,
|
||||
});
|
||||
|
||||
if (!authenticatedEnv) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authenticatedEnv.type === "PUBLIC_JWT") {
|
||||
return {
|
||||
type: "fixedWindow",
|
||||
window: env.API_RATE_LIMIT_JWT_WINDOW,
|
||||
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
|
||||
};
|
||||
} else {
|
||||
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
|
||||
}
|
||||
},
|
||||
pathMatchers: [/^\/api/],
|
||||
// Allow /api/v1/tasks/:id/callback/:secret
|
||||
pathWhiteList: [
|
||||
@@ -159,11 +57,13 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
/^\/api\/v1\/endpoints\/[^\/]+\/[^\/]+\/index\/[^\/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
|
||||
"/api/v1/timezones",
|
||||
"/api/v1/usage/ingest",
|
||||
"/api/v1/auth/jwt/claims",
|
||||
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
|
||||
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
export type AuthorizationAction = "read"; // Add more actions as needed
|
||||
|
||||
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
|
||||
|
||||
export type AuthorizationResources = {
|
||||
[key in (typeof ResourceTypes)[number]]?: string | string[];
|
||||
};
|
||||
|
||||
export type AuthorizationEntity = {
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given entity is authorized to perform a specific action on a resource.
|
||||
*
|
||||
* @param entity - The entity requesting authorization.
|
||||
* @param action - The action the entity wants to perform.
|
||||
* @param resource - The resource on which the action is to be performed.
|
||||
* @param superScopes - An array of super scopes that can bypass the normal authorization checks.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { checkAuthorization } from "./authorization.server";
|
||||
*
|
||||
* const entity = {
|
||||
* type: "PUBLIC",
|
||||
* scope: ["read:runs:run_1234", "read:tasks"]
|
||||
* };
|
||||
*
|
||||
* checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true
|
||||
* checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false
|
||||
* checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true
|
||||
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
|
||||
* ```
|
||||
*/
|
||||
export function checkAuthorization(
|
||||
entity: AuthorizationEntity,
|
||||
action: AuthorizationAction,
|
||||
resource: AuthorizationResources,
|
||||
superScopes?: string[]
|
||||
) {
|
||||
// "PRIVATE" is a secret key and has access to everything
|
||||
if (entity.type === "PRIVATE") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// "PUBLIC" is a deprecated key and has no access
|
||||
if (entity.type === "PUBLIC") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the entity has no permissions, deny access
|
||||
if (!entity.scopes || entity.scopes.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the resource object is empty, deny access
|
||||
if (Object.keys(resource).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for any of the super scopes
|
||||
if (superScopes && superScopes.length > 0) {
|
||||
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResource = Object.keys(resource).reduce((acc, key) => {
|
||||
if (ResourceTypes.includes(key)) {
|
||||
acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources];
|
||||
}
|
||||
return acc;
|
||||
}, {} as AuthorizationResources);
|
||||
|
||||
// Check each resource type
|
||||
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
|
||||
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
|
||||
|
||||
let resourceAuthorized = false;
|
||||
for (const value of resourceValues) {
|
||||
// Check for specific resource permission
|
||||
const specificPermission = `${action}:${resourceType}:${value}`;
|
||||
// Check for general resource type permission
|
||||
const generalPermission = `${action}:${resourceType}`;
|
||||
|
||||
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
|
||||
resourceAuthorized = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If any resource is not authorized, return false
|
||||
if (!resourceAuthorized) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All resources are authorized
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
|
||||
import { RedisOptions } from "ioredis";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { createRedisRateLimitClient, Duration, RateLimiter } from "./rateLimiter.server";
|
||||
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
||||
|
||||
const DurationSchema = z.custom<Duration>((value) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Duration must be a string");
|
||||
}
|
||||
|
||||
return value as Duration;
|
||||
});
|
||||
|
||||
export const RateLimitFixedWindowConfig = z.object({
|
||||
type: z.literal("fixedWindow"),
|
||||
window: DurationSchema,
|
||||
tokens: z.number(),
|
||||
});
|
||||
|
||||
export type RateLimitFixedWindowConfig = z.infer<typeof RateLimitFixedWindowConfig>;
|
||||
|
||||
export const RateLimitSlidingWindowConfig = z.object({
|
||||
type: z.literal("slidingWindow"),
|
||||
window: DurationSchema,
|
||||
tokens: z.number(),
|
||||
});
|
||||
|
||||
export type RateLimitSlidingWindowConfig = z.infer<typeof RateLimitSlidingWindowConfig>;
|
||||
|
||||
export const RateLimitTokenBucketConfig = z.object({
|
||||
type: z.literal("tokenBucket"),
|
||||
refillRate: z.number(),
|
||||
interval: DurationSchema,
|
||||
maxTokens: z.number(),
|
||||
});
|
||||
|
||||
export type RateLimitTokenBucketConfig = z.infer<typeof RateLimitTokenBucketConfig>;
|
||||
|
||||
export const RateLimiterConfig = z.discriminatedUnion("type", [
|
||||
RateLimitFixedWindowConfig,
|
||||
RateLimitSlidingWindowConfig,
|
||||
RateLimitTokenBucketConfig,
|
||||
]);
|
||||
|
||||
export type RateLimiterConfig = z.infer<typeof RateLimiterConfig>;
|
||||
|
||||
type LimitConfigOverrideFunction = (authorizationValue: string) => Promise<unknown>;
|
||||
|
||||
type Options = {
|
||||
redis?: RedisOptions;
|
||||
keyPrefix: string;
|
||||
pathMatchers: (RegExp | string)[];
|
||||
pathWhiteList?: (RegExp | string)[];
|
||||
defaultLimiter: RateLimiterConfig;
|
||||
limiterConfigOverride?: LimitConfigOverrideFunction;
|
||||
limiterCache?: {
|
||||
fresh: number;
|
||||
stale: number;
|
||||
};
|
||||
log?: {
|
||||
requests?: boolean;
|
||||
rejections?: boolean;
|
||||
limiter?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
async function resolveLimitConfig(
|
||||
authorizationValue: string,
|
||||
hashedAuthorizationValue: string,
|
||||
defaultLimiter: RateLimiterConfig,
|
||||
cache: UnkeyCache<{ limiter: RateLimiterConfig }>,
|
||||
logsEnabled: boolean,
|
||||
limiterConfigOverride?: LimitConfigOverrideFunction
|
||||
): Promise<RateLimiterConfig> {
|
||||
if (!limiterConfigOverride) {
|
||||
return defaultLimiter;
|
||||
}
|
||||
|
||||
if (logsEnabled) {
|
||||
logger.info("RateLimiter: checking for override", {
|
||||
authorizationValue: hashedAuthorizationValue,
|
||||
defaultLimiter,
|
||||
});
|
||||
}
|
||||
|
||||
const cacheResult = await cache.limiter.swr(hashedAuthorizationValue, async (key) => {
|
||||
const override = await limiterConfigOverride(authorizationValue);
|
||||
|
||||
if (!override) {
|
||||
if (logsEnabled) {
|
||||
logger.info("RateLimiter: no override found", {
|
||||
authorizationValue,
|
||||
defaultLimiter,
|
||||
});
|
||||
}
|
||||
|
||||
return defaultLimiter;
|
||||
}
|
||||
|
||||
const parsedOverride = RateLimiterConfig.safeParse(override);
|
||||
|
||||
if (!parsedOverride.success) {
|
||||
logger.error("Error parsing rate limiter override", {
|
||||
override,
|
||||
errors: parsedOverride.error.errors,
|
||||
});
|
||||
|
||||
return defaultLimiter;
|
||||
}
|
||||
|
||||
if (logsEnabled && parsedOverride.data) {
|
||||
logger.info("RateLimiter: override found", {
|
||||
authorizationValue,
|
||||
defaultLimiter,
|
||||
override: parsedOverride.data,
|
||||
});
|
||||
}
|
||||
|
||||
return parsedOverride.data;
|
||||
});
|
||||
|
||||
return cacheResult.val ?? defaultLimiter;
|
||||
}
|
||||
|
||||
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
|
||||
export function authorizationRateLimitMiddleware({
|
||||
redis,
|
||||
keyPrefix,
|
||||
defaultLimiter,
|
||||
pathMatchers,
|
||||
pathWhiteList = [],
|
||||
log = {
|
||||
rejections: true,
|
||||
requests: true,
|
||||
},
|
||||
limiterCache,
|
||||
limiterConfigOverride,
|
||||
}: Options) {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({ persistentMap: new Map() });
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
connection: {
|
||||
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
|
||||
...redis,
|
||||
},
|
||||
});
|
||||
|
||||
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
|
||||
const cache = createCache({
|
||||
limiter: new Namespace<RateLimiterConfig>(ctx, {
|
||||
stores: [memory, redisCacheStore],
|
||||
fresh: limiterCache?.fresh ?? 30_000,
|
||||
stale: limiterCache?.stale ?? 60_000,
|
||||
}),
|
||||
});
|
||||
|
||||
const redisClient = createRedisRateLimitClient(
|
||||
redis ?? {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
}
|
||||
);
|
||||
|
||||
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
|
||||
}
|
||||
|
||||
// allow OPTIONS requests
|
||||
if (req.method.toUpperCase() === "OPTIONS") {
|
||||
return next();
|
||||
}
|
||||
|
||||
//first check if any of the pathMatchers match the request path
|
||||
const path = req.path;
|
||||
if (
|
||||
!pathMatchers.some((matcher) =>
|
||||
matcher instanceof RegExp ? matcher.test(path) : path === matcher
|
||||
)
|
||||
) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if the path matches any of the whitelisted paths
|
||||
if (
|
||||
pathWhiteList.some((matcher) =>
|
||||
matcher instanceof RegExp ? matcher.test(path) : path === matcher
|
||||
)
|
||||
) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
|
||||
}
|
||||
|
||||
const authorizationValue = req.headers.authorization;
|
||||
if (!authorizationValue) {
|
||||
if (log.requests) {
|
||||
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
|
||||
}
|
||||
res.setHeader("Content-Type", "application/problem+json");
|
||||
return res.status(401).send(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: "Unauthorized",
|
||||
status: 401,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
|
||||
detail: "No authorization header provided",
|
||||
error: "No authorization header provided",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const hash = createHash("sha256");
|
||||
hash.update(authorizationValue);
|
||||
const hashedAuthorizationValue = hash.digest("hex");
|
||||
|
||||
const limiterConfig = await resolveLimitConfig(
|
||||
authorizationValue,
|
||||
hashedAuthorizationValue,
|
||||
defaultLimiter,
|
||||
cache,
|
||||
typeof log.limiter === "boolean" ? log.limiter : false,
|
||||
limiterConfigOverride
|
||||
);
|
||||
|
||||
const limiter =
|
||||
limiterConfig.type === "fixedWindow"
|
||||
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
|
||||
: limiterConfig.type === "tokenBucket"
|
||||
? Ratelimit.tokenBucket(
|
||||
limiterConfig.refillRate,
|
||||
limiterConfig.interval,
|
||||
limiterConfig.maxTokens
|
||||
)
|
||||
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
|
||||
|
||||
const rateLimiter = new RateLimiter({
|
||||
redisClient,
|
||||
keyPrefix,
|
||||
limiter,
|
||||
logSuccess: log.requests,
|
||||
logFailure: log.rejections,
|
||||
});
|
||||
|
||||
const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue);
|
||||
|
||||
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
|
||||
|
||||
res.set("x-ratelimit-limit", limit.toString());
|
||||
res.set("x-ratelimit-remaining", $remaining.toString());
|
||||
res.set("x-ratelimit-reset", reset.toString());
|
||||
|
||||
if (success) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/problem+json");
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
return res.status(429).send(
|
||||
JSON.stringify(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
limit,
|
||||
remaining,
|
||||
secondsUntilReset,
|
||||
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
|
||||
@@ -1,5 +1,13 @@
|
||||
import { BillingClient, Limits, SetPlanBody, UsageSeriesParams } from "@trigger.dev/platform/v3";
|
||||
import { Organization, Project } from "@trigger.dev/database";
|
||||
import {
|
||||
BillingClient,
|
||||
Limits,
|
||||
SetPlanBody,
|
||||
UsageSeriesParams,
|
||||
UsageResult,
|
||||
} from "@trigger.dev/platform/v3";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
@@ -7,10 +15,61 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
|
||||
import { createEnvironment } from "~/models/organization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
||||
|
||||
function initializeClient() {
|
||||
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
|
||||
const client = new BillingClient({
|
||||
url: process.env.BILLING_API_URL,
|
||||
apiKey: process.env.BILLING_API_KEY,
|
||||
});
|
||||
console.log(`🤑 Billing client initialized: ${process.env.BILLING_API_URL}`);
|
||||
return client;
|
||||
} else {
|
||||
console.log(`🤑 Billing client not initialized`);
|
||||
}
|
||||
}
|
||||
|
||||
const client = singleton("billingClient", initializeClient);
|
||||
|
||||
function initializePlatformCache() {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({ persistentMap: new Map() });
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
connection: {
|
||||
keyPrefix: "tr:cache:platform:v3",
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
});
|
||||
|
||||
// This cache holds the limits fetched from the platform service
|
||||
const cache = createCache({
|
||||
limits: new Namespace<number>(ctx, {
|
||||
stores: [memory, redisCacheStore],
|
||||
fresh: 60_000 * 5, // 5 minutes
|
||||
stale: 60_000 * 10, // 10 minutes
|
||||
}),
|
||||
usage: new Namespace<UsageResult>(ctx, {
|
||||
stores: [memory, redisCacheStore],
|
||||
fresh: 60_000 * 5, // 5 minutes
|
||||
stale: 60_000 * 10, // 10 minutes
|
||||
}),
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
const platformCache = singleton("platformCache", initializePlatformCache);
|
||||
|
||||
export async function getCurrentPlan(orgId: string) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.currentPlan(orgId);
|
||||
|
||||
@@ -60,8 +119,8 @@ export async function getCurrentPlan(orgId: string) {
|
||||
}
|
||||
|
||||
export async function getLimits(orgId: string) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.currentPlan(orgId);
|
||||
if (!result.success) {
|
||||
@@ -87,9 +146,15 @@ export async function getLimit(orgId: string, limit: keyof Limits, fallback: num
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) {
|
||||
return platformCache.limits.swr(`${orgId}:${limit}`, async () => {
|
||||
return getLimit(orgId, limit, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
export async function customerPortalUrl(orgId: string, orgSlug: string) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
return client.createPortalSession(orgId, {
|
||||
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
|
||||
@@ -101,8 +166,8 @@ export async function customerPortalUrl(orgId: string, orgSlug: string) {
|
||||
}
|
||||
|
||||
export async function getPlans() {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.plans();
|
||||
if (!result.success) {
|
||||
@@ -122,7 +187,6 @@ export async function setPlan(
|
||||
callerPath: string,
|
||||
plan: SetPlanBody
|
||||
) {
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
|
||||
}
|
||||
@@ -178,8 +242,8 @@ export async function setPlan(
|
||||
}
|
||||
|
||||
export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.usage(organizationId, { from, to });
|
||||
if (!result.success) {
|
||||
@@ -193,9 +257,27 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
|
||||
const client = getClient();
|
||||
export async function getCachedUsage(
|
||||
organizationId: string,
|
||||
{ from, to }: { from: Date; to: Date }
|
||||
) {
|
||||
if (!client) return undefined;
|
||||
|
||||
const result = await platformCache.usage.swr(
|
||||
`${organizationId}:${from.toISOString()}:${to.toISOString()}`,
|
||||
async () => {
|
||||
const usageResponse = await getUsage(organizationId, { from, to });
|
||||
|
||||
return usageResponse;
|
||||
}
|
||||
);
|
||||
|
||||
return result.val;
|
||||
}
|
||||
|
||||
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.usageSeries(organizationId, params);
|
||||
if (!result.success) {
|
||||
@@ -214,8 +296,8 @@ export async function reportInvocationUsage(
|
||||
costInCents: number,
|
||||
additionalData?: Record<string, any>
|
||||
) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.reportInvocationUsage({
|
||||
organizationId,
|
||||
@@ -234,8 +316,8 @@ export async function reportInvocationUsage(
|
||||
}
|
||||
|
||||
export async function reportComputeUsage(request: Request) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
return fetch(`${process.env.BILLING_API_URL}/api/v1/usage/ingest/compute`, {
|
||||
method: "POST",
|
||||
headers: request.headers,
|
||||
@@ -244,8 +326,8 @@ export async function reportComputeUsage(request: Request) {
|
||||
}
|
||||
|
||||
export async function getEntitlement(organizationId: string) {
|
||||
const client = getClient();
|
||||
if (!client) return undefined;
|
||||
|
||||
try {
|
||||
const result = await client.getEntitlement(organizationId);
|
||||
if (!result.success) {
|
||||
@@ -275,19 +357,6 @@ export async function projectCreated(organization: Organization, project: Projec
|
||||
}
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
|
||||
const client = new BillingClient({
|
||||
url: process.env.BILLING_API_URL,
|
||||
apiKey: process.env.BILLING_API_KEY,
|
||||
});
|
||||
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
|
||||
return client;
|
||||
} else {
|
||||
console.log(`Billing client not initialized`);
|
||||
}
|
||||
}
|
||||
|
||||
function isCloud(): boolean {
|
||||
const acceptableHosts = [
|
||||
"https://cloud.trigger.dev",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { logger } from "./logger.server";
|
||||
|
||||
type Options = {
|
||||
redis?: RedisOptions;
|
||||
redisClient?: RateLimiterRedisClient;
|
||||
keyPrefix: string;
|
||||
limiter: Limiter;
|
||||
logSuccess?: boolean;
|
||||
@@ -14,34 +15,32 @@ type Options = {
|
||||
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
|
||||
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
|
||||
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
|
||||
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
|
||||
|
||||
export class RateLimiter {
|
||||
#ratelimit: Ratelimit;
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
const { redis, keyPrefix, limiter } = options;
|
||||
const { redis, redisClient, keyPrefix, limiter } = options;
|
||||
const prefix = `ratelimit:${keyPrefix}`;
|
||||
this.#ratelimit = new Ratelimit({
|
||||
redis: createRedisRateLimitClient(
|
||||
redis ?? {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
}
|
||||
),
|
||||
redis:
|
||||
redisClient ??
|
||||
createRedisRateLimitClient(
|
||||
redis ?? {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
}
|
||||
),
|
||||
limiter,
|
||||
ephemeralCache: new Map(),
|
||||
analytics: false,
|
||||
prefix,
|
||||
});
|
||||
|
||||
logger.info(`RateLimiter (${keyPrefix}): initialized`, {
|
||||
keyPrefix,
|
||||
redisKeyspace: prefix,
|
||||
});
|
||||
}
|
||||
|
||||
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
|
||||
@@ -71,9 +70,7 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
export function createRedisRateLimitClient(
|
||||
redisOptions: RedisOptions
|
||||
): ConstructorParameters<typeof Ratelimit>[0]["redis"] {
|
||||
export function createRedisRateLimitClient(redisOptions: RedisOptions): RateLimiterRedisClient {
|
||||
const redis = new Redis(redisOptions);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
|
||||
|
||||
export async function validatePublicJwtKey(token: string) {
|
||||
// Get the sub claim from the token
|
||||
// Use the sub claim to find the environment
|
||||
// Validate the token against the environment.apiKey
|
||||
// Once that's done, return the environment and the claims
|
||||
const sub = extractJWTSub(token);
|
||||
|
||||
if (!sub) {
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentById(sub);
|
||||
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const claims = await validateJWT(token, environment.apiKey);
|
||||
|
||||
if (!claims) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
environment,
|
||||
claims,
|
||||
};
|
||||
}
|
||||
|
||||
export function isPublicJWT(token: string): boolean {
|
||||
// Split the token
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
try {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(decodeBase64Url(parts[1]));
|
||||
|
||||
if (payload === null || typeof payload !== "object") return false;
|
||||
|
||||
// Check for the pub: true claim
|
||||
return "pub" in payload && payload.pub === true;
|
||||
} catch (error) {
|
||||
// If there's any error in decoding or parsing, it's not a valid JWT
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJWTSub(token: string): string | undefined {
|
||||
// Split the token
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return;
|
||||
|
||||
try {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(decodeBase64Url(parts[1]));
|
||||
|
||||
if (payload === null || typeof payload !== "object") return;
|
||||
|
||||
// Check for the pub: true claim
|
||||
return "sub" in payload && typeof payload.sub === "string" ? payload.sub : undefined;
|
||||
} catch (error) {
|
||||
// If there's any error in decoding or parsing, it's not a valid JWT
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(str: string): string {
|
||||
// Replace URL-safe characters and add padding
|
||||
str = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
switch (str.length % 4) {
|
||||
case 2:
|
||||
str += "==";
|
||||
break;
|
||||
case 3:
|
||||
str += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode using Node.js Buffer
|
||||
return Buffer.from(str, "base64").toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import Redis, { Callback, Result, type RedisOptions } from "ioredis";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { longPollingFetch } from "~/utils/longPollingFetch";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
export interface CachedLimitProvider {
|
||||
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
|
||||
}
|
||||
|
||||
export type RealtimeClientOptions = {
|
||||
electricOrigin: string;
|
||||
redis: RedisOptions;
|
||||
cachedLimitProvider: CachedLimitProvider;
|
||||
keyPrefix: string;
|
||||
expiryTimeInSeconds?: number;
|
||||
};
|
||||
|
||||
export type RealtimeEnvironment = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type RealtimeRunsParams = {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export class RealtimeClient {
|
||||
private redis: Redis;
|
||||
private expiryTimeInSeconds: number;
|
||||
private cachedLimitProvider: CachedLimitProvider;
|
||||
|
||||
constructor(private options: RealtimeClientOptions) {
|
||||
this.redis = new Redis(options.redis);
|
||||
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5; // default to 5 minutes
|
||||
this.cachedLimitProvider = options.cachedLimitProvider;
|
||||
this.#registerCommands();
|
||||
}
|
||||
|
||||
async streamRun(url: URL | string, environment: RealtimeEnvironment, runId: string) {
|
||||
return this.#streamRunsWhere(url, environment, `id='${runId}'`);
|
||||
}
|
||||
|
||||
async streamBatch(url: URL | string, environment: RealtimeEnvironment, batchId: string) {
|
||||
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`);
|
||||
}
|
||||
|
||||
async streamRuns(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
params: RealtimeRunsParams
|
||||
) {
|
||||
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
|
||||
|
||||
if (params.tags) {
|
||||
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
|
||||
}
|
||||
|
||||
const whereClause = whereClauses.join(" AND ");
|
||||
|
||||
return this.#streamRunsWhere(url, environment, whereClause);
|
||||
}
|
||||
|
||||
async #streamRunsWhere(url: URL | string, environment: RealtimeEnvironment, whereClause: string) {
|
||||
const electricUrl = this.#constructElectricUrl(url, whereClause);
|
||||
|
||||
return this.#performElectricRequest(electricUrl, environment);
|
||||
}
|
||||
|
||||
#constructElectricUrl(url: URL | string, whereClause: string): URL {
|
||||
const $url = new URL(url.toString());
|
||||
|
||||
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape/public."TaskRun"`);
|
||||
|
||||
// Copy over all the url search params to the electric url
|
||||
$url.searchParams.forEach((value, key) => {
|
||||
electricUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
// const electricParams = ["shape_id", "live", "offset", "columns", "cursor"];
|
||||
|
||||
// electricParams.forEach((param) => {
|
||||
// if ($url.searchParams.has(param) && $url.searchParams.get(param)) {
|
||||
// electricUrl.searchParams.set(param, $url.searchParams.get(param)!);
|
||||
// }
|
||||
// });
|
||||
|
||||
electricUrl.searchParams.set("where", whereClause);
|
||||
|
||||
return electricUrl;
|
||||
}
|
||||
|
||||
async #performElectricRequest(url: URL, environment: RealtimeEnvironment) {
|
||||
const shapeId = extractShapeId(url);
|
||||
|
||||
logger.debug("[realtimeClient] request", {
|
||||
url: url.toString(),
|
||||
});
|
||||
|
||||
if (!shapeId) {
|
||||
// If the shapeId is not present, we're just getting the initial value
|
||||
return longPollingFetch(url.toString());
|
||||
}
|
||||
|
||||
const isLive = isLiveRequestUrl(url);
|
||||
|
||||
if (!isLive) {
|
||||
return longPollingFetch(url.toString());
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
|
||||
// We now need to wrap the longPollingFetch in a concurrency tracker
|
||||
const concurrencyLimit = await this.cachedLimitProvider.getCachedLimit(
|
||||
environment.organizationId,
|
||||
100_000
|
||||
);
|
||||
|
||||
if (!concurrencyLimit) {
|
||||
logger.error("Failed to get concurrency limit", {
|
||||
organizationId: environment.organizationId,
|
||||
});
|
||||
|
||||
return json({ error: "Failed to get concurrency limit" }, { status: 500 });
|
||||
}
|
||||
|
||||
logger.debug("[realtimeClient] increment and check", {
|
||||
concurrencyLimit,
|
||||
shapeId,
|
||||
requestId,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
const canProceed = await this.#incrementAndCheck(environment.id, requestId, concurrencyLimit);
|
||||
|
||||
if (!canProceed) {
|
||||
logger.debug("[realtimeClient] too many concurrent requests", {
|
||||
requestId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
return json({ error: "Too many concurrent requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
try {
|
||||
// ... (rest of your existing code for the long polling request)
|
||||
const response = await longPollingFetch(url.toString());
|
||||
|
||||
// Decrement the counter after the long polling request is complete
|
||||
await this.#decrementConcurrency(environment.id, requestId);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Decrement the counter if the request fails
|
||||
await this.#decrementConcurrency(environment.id, requestId);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #incrementAndCheck(environmentId: string, requestId: string, limit: number) {
|
||||
const key = this.#getKey(environmentId);
|
||||
const now = Date.now();
|
||||
|
||||
const result = await this.redis.incrementAndCheckConcurrency(
|
||||
key,
|
||||
now.toString(),
|
||||
requestId,
|
||||
this.expiryTimeInSeconds.toString(), // expiry time
|
||||
(now - this.expiryTimeInSeconds * 1000).toString(), // cutoff time
|
||||
limit.toString()
|
||||
);
|
||||
|
||||
return result === 1;
|
||||
}
|
||||
|
||||
async #decrementConcurrency(environmentId: string, requestId: string) {
|
||||
logger.debug("[realtimeClient] decrement", {
|
||||
requestId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const key = this.#getKey(environmentId);
|
||||
|
||||
await this.redis.zrem(key, requestId);
|
||||
}
|
||||
|
||||
#getKey(environmentId: string): string {
|
||||
return `${this.options.keyPrefix}:${environmentId}`;
|
||||
}
|
||||
|
||||
#registerCommands() {
|
||||
this.redis.defineCommand("incrementAndCheckConcurrency", {
|
||||
numberOfKeys: 1,
|
||||
lua: /* lua */ `
|
||||
local concurrencyKey = KEYS[1]
|
||||
|
||||
local timestamp = tonumber(ARGV[1])
|
||||
local requestId = ARGV[2]
|
||||
local expiryTime = tonumber(ARGV[3])
|
||||
local cutoffTime = tonumber(ARGV[4])
|
||||
local limit = tonumber(ARGV[5])
|
||||
|
||||
-- Remove expired entries
|
||||
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
|
||||
|
||||
-- Add the new request to the sorted set
|
||||
redis.call('ZADD', concurrencyKey, timestamp, requestId)
|
||||
|
||||
-- Set the expiry time on the key
|
||||
redis.call('EXPIRE', concurrencyKey, expiryTime)
|
||||
|
||||
-- Get the total number of concurrent requests
|
||||
local totalRequests = redis.call('ZCARD', concurrencyKey)
|
||||
|
||||
-- Check if the limit has been exceeded
|
||||
if totalRequests > limit then
|
||||
-- Remove the request we just added
|
||||
redis.call('ZREM', concurrencyKey, requestId)
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Return 1 to indicate success
|
||||
return 1
|
||||
`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractShapeId(url: URL) {
|
||||
return url.searchParams.get("shape_id");
|
||||
}
|
||||
|
||||
function isLiveRequestUrl(url: URL) {
|
||||
return url.searchParams.has("live") && url.searchParams.get("live") === "true";
|
||||
}
|
||||
|
||||
declare module "ioredis" {
|
||||
interface RedisCommander<Context> {
|
||||
incrementAndCheckConcurrency(
|
||||
key: string,
|
||||
timestamp: string,
|
||||
requestId: string,
|
||||
expiryTime: string,
|
||||
cutoffTime: string,
|
||||
limit: string,
|
||||
callback?: Callback<number>
|
||||
): Result<number, Context>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RealtimeClient } from "./realtimeClient.server";
|
||||
import { getCachedLimit } from "./platform.v3.server";
|
||||
|
||||
function initializeRealtimeClient() {
|
||||
return new RealtimeClient({
|
||||
electricOrigin: env.ELECTRIC_ORIGIN,
|
||||
keyPrefix: "tr:realtime:concurrency",
|
||||
redis: {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
username: env.REDIS_USERNAME,
|
||||
password: env.REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
cachedLimitProvider: {
|
||||
async getCachedLimit(organizationId, defaultValue) {
|
||||
const result = await getCachedLimit(
|
||||
organizationId,
|
||||
"realtimeConcurrentConnections",
|
||||
defaultValue
|
||||
);
|
||||
|
||||
return result.val;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const realtimeClient = singleton("realtimeClient", initializeRealtimeClient);
|
||||
@@ -0,0 +1,260 @@
|
||||
import { z } from "zod";
|
||||
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
|
||||
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import {
|
||||
AuthorizationAction,
|
||||
AuthorizationResources,
|
||||
checkAuthorization,
|
||||
} from "../authorization.server";
|
||||
import { logger } from "../logger.server";
|
||||
import {
|
||||
authenticateApiRequestWithPersonalAccessToken,
|
||||
PersonalAccessTokenAuthenticationResult,
|
||||
} from "../personalAccessToken.server";
|
||||
|
||||
type ApiKeyRouteBuilderOptions<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined
|
||||
) => AuthorizationResources;
|
||||
superScopes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type ApiKeyHandlerFunction<
|
||||
TParamsSchema extends z.AnyZodObject | undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined
|
||||
> = (args: {
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined;
|
||||
authentication: ApiAuthenticationResult;
|
||||
request: Request;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export function createLoaderApiRoute<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
|
||||
>(
|
||||
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
|
||||
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema>
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const {
|
||||
params: paramsSchema,
|
||||
searchParams: searchParamsSchema,
|
||||
allowJWT = false,
|
||||
corsStrategy = "none",
|
||||
authorization,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
|
||||
|
||||
if (!authenticationResult) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
let parsedParams: any = undefined;
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
parsedParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedSearchParams: any = undefined;
|
||||
if (searchParamsSchema) {
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
parsedSearchParams = parsed.data;
|
||||
}
|
||||
|
||||
if (authorization) {
|
||||
const { action, resource, superScopes } = authorization;
|
||||
const $resource = resource(parsedParams, parsedSearchParams);
|
||||
|
||||
logger.debug("Checking authorization", {
|
||||
action,
|
||||
resource: $resource,
|
||||
superScopes,
|
||||
scopes: authenticationResult.scopes,
|
||||
});
|
||||
|
||||
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Unauthorized" }, { status: 403 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler({
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
});
|
||||
return wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
console.error("Error in API route:", error);
|
||||
if (error instanceof Response) {
|
||||
return wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type PATRouteBuilderOptions<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
|
||||
> = {
|
||||
params?: TParamsSchema;
|
||||
searchParams?: TSearchParamsSchema;
|
||||
corsStrategy?: "all" | "none";
|
||||
};
|
||||
|
||||
type PATHandlerFunction<
|
||||
TParamsSchema extends z.AnyZodObject | undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined
|
||||
> = (args: {
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
|
||||
searchParams: TSearchParamsSchema extends z.AnyZodObject
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined;
|
||||
authentication: PersonalAccessTokenAuthenticationResult;
|
||||
request: Request;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export function createLoaderPATApiRoute<
|
||||
TParamsSchema extends z.AnyZodObject | undefined = undefined,
|
||||
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
|
||||
>(
|
||||
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
|
||||
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema>
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const {
|
||||
params: paramsSchema,
|
||||
searchParams: searchParamsSchema,
|
||||
corsStrategy = "none",
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
let parsedParams: any = undefined;
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
parsedParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedSearchParams: any = undefined;
|
||||
if (searchParamsSchema) {
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = searchParamsSchema.safeParse(searchParams);
|
||||
if (!parsed.success) {
|
||||
return wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
parsedSearchParams = parsed.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler({
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
authentication: authenticationResult,
|
||||
request,
|
||||
});
|
||||
return wrapResponse(request, result, corsStrategy !== "none");
|
||||
} catch (error) {
|
||||
console.error("Error in API route:", error);
|
||||
if (error instanceof Response) {
|
||||
return wrapResponse(request, error, corsStrategy !== "none");
|
||||
}
|
||||
return wrapResponse(
|
||||
request,
|
||||
json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function wrapResponse(request: Request, response: Response, useCors: boolean) {
|
||||
return useCors ? apiCors(request, response) : response;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { JobHelpers, Task } from "graphile-worker";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { logger } from "./logger.server";
|
||||
import { ZodWorkerRateLimiter } from "~/platform/zodWorker.server";
|
||||
import { ZodWorkerRateLimiter } from "@internal/zod-worker";
|
||||
import {
|
||||
ConcurrencyLimitGroup,
|
||||
JobRun,
|
||||
@@ -117,7 +117,7 @@ if currentSize < maxSize then
|
||||
return true
|
||||
else
|
||||
redis.call('SADD', forbiddenFlagsKey, forbiddenFlag)
|
||||
|
||||
|
||||
return false
|
||||
end
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Err, Ok, type Result } from "@unkey/error";
|
||||
import type { Entry, Store } from "@unkey/cache/stores";
|
||||
import type { RedisOptions } from "ioredis";
|
||||
import { Redis } from "ioredis";
|
||||
import { CacheError } from "@unkey/cache";
|
||||
|
||||
export type RedisCacheStoreConfig = {
|
||||
connection: RedisOptions;
|
||||
};
|
||||
|
||||
export class RedisCacheStore<TNamespace extends string, TValue = any>
|
||||
implements Store<TNamespace, TValue>
|
||||
{
|
||||
public readonly name = "redis";
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(config: RedisCacheStoreConfig) {
|
||||
this.redis = new Redis(config.connection);
|
||||
}
|
||||
|
||||
private buildCacheKey(namespace: TNamespace, key: string): string {
|
||||
return [namespace, key].join("::");
|
||||
}
|
||||
|
||||
public async get(
|
||||
namespace: TNamespace,
|
||||
key: string
|
||||
): Promise<Result<Entry<TValue> | undefined, CacheError>> {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = await this.redis.get(this.buildCacheKey(namespace, key));
|
||||
} catch (err) {
|
||||
return Err(
|
||||
new CacheError({
|
||||
tier: this.name,
|
||||
key,
|
||||
message: (err as Error).message,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
return Promise.resolve(Ok(undefined));
|
||||
}
|
||||
|
||||
try {
|
||||
const superjson = await import("superjson");
|
||||
const entry = superjson.parse(raw) as Entry<TValue>;
|
||||
return Ok(entry);
|
||||
} catch (err) {
|
||||
return Err(
|
||||
new CacheError({
|
||||
tier: this.name,
|
||||
key,
|
||||
message: (err as Error).message,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async set(
|
||||
namespace: TNamespace,
|
||||
key: string,
|
||||
entry: Entry<TValue>
|
||||
): Promise<Result<void, CacheError>> {
|
||||
const cacheKey = this.buildCacheKey(namespace, key);
|
||||
try {
|
||||
const superjson = await import("superjson");
|
||||
await this.redis.set(cacheKey, superjson.stringify(entry), "PXAT", entry.staleUntil);
|
||||
return Ok();
|
||||
} catch (err) {
|
||||
return Err(
|
||||
new CacheError({
|
||||
tier: this.name,
|
||||
key,
|
||||
message: (err as Error).message,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async remove(namespace: TNamespace, key: string): Promise<Result<void, CacheError>> {
|
||||
try {
|
||||
const cacheKey = this.buildCacheKey(namespace, key);
|
||||
await this.redis.del(cacheKey);
|
||||
return Promise.resolve(Ok());
|
||||
} catch (err) {
|
||||
return Err(
|
||||
new CacheError({
|
||||
tier: this.name,
|
||||
key,
|
||||
message: (err as Error).message,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DeliverEmailSchema } from "@/../../packages/emails/src";
|
||||
import { DeliverEmailSchema } from "emails";
|
||||
import { ScheduledPayloadSchema, addMissingVersionField } from "@trigger.dev/core";
|
||||
import { ZodWorker } from "@internal/zod-worker";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { ZodWorker } from "~/platform/zodWorker.server";
|
||||
import { MarqsConcurrencyMonitor } from "~/v3/marqs/concurrencyMonitor.server";
|
||||
import { RequeueV2Message } from "~/v3/marqs/requeueV2Message.server";
|
||||
import { RequeueTaskRunService } from "~/v3/requeueTaskRun.server";
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
CancelDevSessionRunsService,
|
||||
CancelDevSessionRunsServiceOptions,
|
||||
} from "~/v3/services/cancelDevSessionRuns.server";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -279,6 +280,7 @@ function getWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "workerQueue",
|
||||
prisma,
|
||||
replica: $replica,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: env.WORKER_CONCURRENCY,
|
||||
@@ -287,6 +289,7 @@ function getWorkerQueue() {
|
||||
schema: env.WORKER_SCHEMA,
|
||||
maxPoolSize: env.WORKER_CONCURRENCY + 1,
|
||||
},
|
||||
logger: logger,
|
||||
shutdownTimeoutInMs: env.GRACEFUL_SHUTDOWN_TIMEOUT,
|
||||
schema: workerCatalog,
|
||||
recurringTasks: {
|
||||
@@ -732,6 +735,8 @@ function getExecutionWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "executionWorker",
|
||||
prisma,
|
||||
replica: $replica,
|
||||
logger: logger,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: env.EXECUTION_WORKER_CONCURRENCY,
|
||||
@@ -786,6 +791,8 @@ function getTaskOperationWorkerQueue() {
|
||||
return new ZodWorker({
|
||||
name: "taskOperationWorker",
|
||||
prisma,
|
||||
replica: $replica,
|
||||
logger: logger,
|
||||
runnerOptions: {
|
||||
connectionString: env.DATABASE_URL,
|
||||
concurrency: env.TASK_OPERATION_WORKER_CONCURRENCY,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user