Compare commits

...

38 Commits

Author SHA1 Message Date
Eric Allam 90b8797fbb Release 3.0.0-beta.31 2024-05-13 16:15:00 -04:00
github-actions[bot] 107f2e7bd5 chore: Update version for release (beta) (#1099)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 16:14:14 -04:00
Eric Allam b8477ea2b0 Fixes an issue with scoped packages in additionalPackages option 2024-05-13 15:31:19 -04:00
Matt Aitken 864498ea85 Latest lockfile 2024-05-13 17:46:02 +01:00
github-actions[bot] 2e21bc1563 chore: Update version for release (beta) (#1094)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-13 17:38:50 +01:00
Matt Aitken 1477a2e309 Set the timeout for canceling checkpoints 2024-05-13 16:50:53 +01:00
Matt Aitken 2081cb15b8 Removed docs for dashboard as they’re empty at the moment. Moved reference docs for Runs and schedules up 2024-05-12 18:29:46 +01:00
Matt Aitken ff551c5cbd Ticked off alerts (in preparation for putting it live) 2024-05-12 18:27:39 +01:00
Matt Aitken 794b25a988 Some fixes for the v3 docs showing v2 items in the sidebar 2024-05-12 18:27:26 +01:00
Matt Aitken 5fdb43ecd1 Bring alerts side menu item back 2024-05-12 18:05:01 +01:00
Matt Aitken 80997bcd6d .env.example Redis env vars are uncommented 2024-05-12 18:02:42 +01:00
Matt Aitken 194f336b84 Added an image of where to set the v3 GitHub action secret 2024-05-12 18:01:27 +01:00
Eric Allam 2e02743170 Resume rate limiting run executions, but reschedule throttled jobs 10 seconds into the future instead of their original run_at, which will help with database load 2024-05-10 15:44:20 -04:00
nicktrn 1642fd7baf perf: migrate to graphile worker v0.16.6 (#1097)
* migrate to graphile worker v0.16.6

* remove stale docs link

* fix jobs cleanup query
2024-05-10 18:07:16 +01:00
Matt Aitken c6449126b1 Don’t start runs if they have runs disabled 2024-05-10 16:11:06 +01:00
Matt Aitken 1ccf38e440 Return a 404 so the webhook provider doesn’t retry 2024-05-10 16:00:42 +01:00
Matt Aitken 29b773cd36 Prevent ingesting events if the org has runs disabled 2024-05-10 15:59:04 +01:00
Matt Aitken 73fb7cfeb3 Prevent HTTP source requests if the org has runs disabled 2024-05-10 15:58:51 +01:00
nicktrn 5431638926 Revert "Revert "Revert "Revert "disable advisory locks""""
This reverts commit 933a14b44c.
2024-05-10 15:09:35 +01:00
Eric Allam 933a14b44c Revert "Revert "Revert "disable advisory locks"""
This reverts commit 4ff07f8a94.
2024-05-10 09:35:53 -04:00
Eric Allam 4ff07f8a94 Revert "Revert "disable advisory locks""
This reverts commit af9957c085.
2024-05-10 09:30:32 -04:00
Eric Allam 0afdb4cc7c Help out the DB 2024-05-10 09:15:20 -04:00
Eric Allam af9957c085 Revert "disable advisory locks"
This reverts commit 892fd9f212.
2024-05-10 09:12:33 -04:00
Eric Allam 5c4275619b v3.1 experiments 2024-05-10 09:11:54 -04:00
nicktrn 892fd9f212 disable advisory locks 2024-05-10 10:21:48 +01:00
James Ritchie cbe9317a86 Added padding to the email body 2024-05-09 17:39:39 +01:00
James Ritchie 6baf9e5294 Dismiss the dev user tooltip in the task page when your mouse leaves the target 2024-05-09 17:05:17 +01:00
James Ritchie a823421324 Small style improvements to the emails 2024-05-09 16:49:45 +01:00
Eric Allam 0e919f56f2 Alerts v1 (#1065)
* Alerts v1

* Encrypt alert webhook secrets and allow them to be generated by the server

* Alert v1 UI

* Remove unnecessary emails

* Move to using `@react-email/components`

* WIP slack alerts

* More slack alerts WIP

* Update pnpm lock after rebase

* Finish implementing Slack alerts

* Use a more error like emoji

* New secondary variant for the segmented control

* Added a simple checkbox style variant to storybook

* Style tweak to the segmented control

* UI improvements to the alert modal

* Use searchable Select for alerts. Changed default variant for SegmentedControl

* Secondary button now using secondary colour

* segmented control style tweak

* Improved the channel column in the alerts table

* Updated logo-mono.png

* Updated email styles

* Email templates updated to new styles

* Don’t log the decrypted secret

* await enqueing the deployment alert when an index fails

* await enqueing the timeout alert

---------

Co-authored-by: James Ritchie <james@jamesritchie.co.uk>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-09 12:12:00 +01:00
Matt Aitken f90960fc13 Better display when there are lots of dev environment (#1093)
* Created an EnvironmentLabels component that puts extra dev environments behind a +

* Added new environment labels components to schedules pages
2024-05-09 10:32:38 +01:00
nicktrn 2306217697 v3: automatic pod cleanup (#1092)
* add automatic pod cleaner

* fix task monitor namespace override
2024-05-09 10:16:00 +01:00
nicktrn cfe4b5409a Fix lockfile 2024-05-08 17:27:34 +01:00
github-actions[bot] 223768a025 chore: Update version for release (beta) (#1091)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-05-08 17:25:22 +01:00
Ramadan Omar 1fd26ff98b Improvement: Added more context to Shopify webhook registering errors (#1090)
* Improvement: Added more context to Shopify webhook registering errors

Co-authored-by: Ahmed Ramadan <ahmedramadan1337@gmail.com>

* revert: shopify reference jobs

* fix: added error handling to some shopify client edge cases

* small changes to crud error handling

---------

Co-authored-by: Ahmed Ramadan <ahmedramadan1337@gmail.com>
Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2024-05-08 17:22:59 +01:00
Eric Allam 8d34c63c2d Fix issue when concurrency limit is set to 0 on a queue 2024-05-08 10:19:06 +01:00
Zafer Cesur ff04c986a1 Add notice on sideEffects in deployment guide (#680)
Co-authored-by: Matt Aitken <matt@mattaitken.com>
2024-05-07 22:58:27 +01:00
Matt Aitken 7d6430dc46 New filtered dropdown and run filters (#1089)
* Extract out useFilterTasks

* WIP on Combobox

* Revert "WIP on Combobox"

This reverts commit f0a9b2e57c67f24d5e0876630e17a54252ceb86b.

* Added ariakit

* Started adding a Listbox component

* Listbox WIP

* More WIP on the list box

* More work on the Listbox

* WIP on filterable Listbox using headless… but it’s going to be a nightmare to make it good. Wrong approach

* WIP using Ariakit

* Multiple selection working

* WIP in reworking it so search is built in and it’s easier to use

* Nicer API for listbox

* Allow sections to be rendered with filtering

* More progress on the styling of the menu

* The empty state

* Removed focus outline

* Allow filtering based on the section title

* Render the shortcut correctly

* Allow passing static children to Select and render a separator if a section has no title

* Static example and added subtle focus

* Allow static data with no items array passed in

* Allow shortcuts on select items

* Only how heading if true

* Tooltip for keyboard shortcut

* useShortcutKeys fixes

- Can be passed an undefined definition (so we render the same number of hooks always).
- Fix for enabledOnInputElements not being used…

* Shortcut keys working with lists

* Don’t render shortcut numbers higher than 0

* More improvements

* WIP on a Link row

* Nicer API for the children function

* Much nicer shortcut key support

* Separated SelectTrigger out

* Heading separated out

* More styled elements created

* Removed unused element

* Reordered statuses

* Added search context

* Fixes for default values

* Removed tab elements

* Combo box

* Simplified it more

* Started work on Filter menu

* Experiments with filter menu

* Filter menu proof of concept working

* Nice hook for using a search param

* The escape key goes back to the previous menu

* Added filter page to storybook

* Added other variants

* Renamed Select file to OldSelect, implemented Feedback form

* Added placeholder support

* Added an optional dropdown icon (defaults to a chevron down)

* Feedback new Select

* Feedback just use static items

* New project page

* Renamed storybook pages

* Lots of progress on the new run filters

* Added new tasks filters

* Show the applied filters

* Added period filtering

* Fix for escape resetting the period value to the previous value when escape is pressed

* Show the Clear button sooner

* Removed unused code

* Reworked useSearchParams so multiple values can be set at once…

* Renamed file: OldSelect -> SImpleSelect

* Fix for new Select not working in forms because props weren’t being passed through

* New Select on the schedules page

* Better layout when there are lots of run filters

* Many style improvements to run filters

* Status filter allows you to select as well

* Rolled out the menus for the other files

* Minor improvements to schedule page and page padding

* Latest lockfile
2024-05-07 22:55:31 +01:00
Dan b289aa31da Readme updates - removed old roadmap and added dev preview EA link (#1078)
* Removed outdated roadmap

* Added link to the dev preview article
2024-05-07 22:55:00 +01:00
201 changed files with 10495 additions and 1683 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fixes an issue with scoped packages in additionalPackages option
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/shopify": patch
---
improved error messages when a shopify webhook fails to register
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Better handle uncaught exceptions
+4
View File
@@ -44,9 +44,11 @@
"@trigger.dev/yalt": "2.3.18"
},
"changesets": [
"afraid-sheep-joke",
"angry-eagles-trade",
"beige-pens-dance",
"big-tomatoes-deliver",
"blue-pumas-whisper",
"breezy-gorillas-mate",
"chilled-hornets-move",
"clean-pianos-listen",
@@ -67,6 +69,7 @@
"khaki-poems-lay",
"late-icons-lie",
"late-steaks-behave",
"lazy-files-lay",
"lemon-jobs-repair",
"light-bulldogs-press",
"light-dragons-complain",
@@ -79,6 +82,7 @@
"mighty-flowers-train",
"nasty-jars-pump",
"new-rivers-tell",
"nice-bulldogs-turn",
"ninety-pets-travel",
"odd-poets-own",
"pink-pumas-rhyme",
+4 -4
View File
@@ -12,10 +12,10 @@ APP_ENV=development
APP_ORIGIN=http://localhost:3030
NODE_ENV=development
# Redis is used for concurrency control
# REDIS_HOST="localhost"
# REDIS_PORT="6379"
# REDIS_TLS_DISABLED="true"
# Redis is used for the v3 queuing and v2 concurrency control
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_TLS_DISABLED="true"
# OPTIONAL VARIABLES
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
+4 -26
View File
@@ -5,7 +5,7 @@
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
</picture>
### The open source background jobs framework
### The open source background jobs platform
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
@@ -14,7 +14,9 @@
</div>
# About Trigger.dev
> The Trigger.dev v3 developer preview is now open. For more information and to get early access, check out our [developer preview launch post](https://trigger.dev/blog/v3-developer-preview-launch/).
## About Trigger.dev
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
@@ -48,30 +50,6 @@ View every Task in every Run so you can tell exactly what happened.
Easily integrate with hundreds of third-party APIs including your own. Use API keys (which never leave your server) or let us handle OAuth for you. Install our integration packages and easily subscribe to webhooks and perform common tasks, or you can easily use your existing favorite Node.JS SDKs and get resumability and idempotency through our `runTask` function.
## Our progress
Were building the most comprehensive and easy-to-use background jobs framework for developers.
Click the links to join the discussions about our upcoming features.
| Feature | What it does | Status |
| ------------------------------------------------------------------------------------ | --------------------------------------------------- | ------ |
| Integration kit | Official Trigger.dev integrations or build your own | ✅ |
| Self-hosting | Host the platform yourself | ✅ |
| Cloud | Just write code, no deployment required | ✅ |
| Dashboard | View every Task in every Run | ✅ |
| Serverless | Long-running Jobs on your serverless backend | ✅ |
| React hooks | Easily update your UI with Job progress | ✅ |
| React frameworks | Support for Remix, Astro, RedwoodJS & more | ✅ |
| [Background tasks](https://github.com/triggerdotdev/trigger.dev/discussions/400) | Offload long or intense Tasks to our infrastructure | 🛠️ |
| [Long-running servers](https://github.com/triggerdotdev/trigger.dev/discussions/430) | Run Jobs on your long-running backend | 🛠️ |
| Polling Triggers | Subscribe to changes without webhooks | 🕝 |
| Vercel integration | Easy deploy and preview environment support | 🕝 |
| Streaming | Receive data from your Jobs in realtime | 🕝 |
| 100+ integrations | Comprehensive support for popular APIs | 🕝 |
| [Trigger.dev Connect](https://github.com/triggerdotdev/trigger.dev/discussions/441) | Use integrations signed in as your users | 🕝 |
| File IO | Create Tasks that have file outputs | 🕝 |
# Getting started
Visit our docs [here](https://trigger.dev/docs).
+9
View File
@@ -10,6 +10,7 @@ import {
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
import { randomUUID } from "crypto";
import { TaskMonitor } from "./taskMonitor";
import { PodCleaner } from "./podCleaner";
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
const NODE_NAME = process.env.NODE_NAME || "local";
@@ -543,3 +544,11 @@ const taskMonitor = new TaskMonitor({
});
taskMonitor.start();
const podCleaner = new PodCleaner({
runtimeEnv: RUNTIME_ENV,
namespace: "default",
intervalInSeconds: 300,
});
podCleaner.start();
+264
View File
@@ -0,0 +1,264 @@
import * as k8s from "@kubernetes/client-node";
import { SimpleLogger } from "@trigger.dev/core-apps";
type PodCleanerOptions = {
runtimeEnv: "local" | "kubernetes";
namespace?: string;
intervalInSeconds?: number;
};
export class PodCleaner {
private enabled = false;
private namespace = "default";
private intervalInSeconds = 300;
private logger = new SimpleLogger("[PodCleaner]");
private k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
};
constructor(private opts: PodCleanerOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
if (opts.intervalInSeconds) {
this.intervalInSeconds = opts.intervalInSeconds;
}
this.k8sClient = this.#createK8sClient();
}
#createK8sClient() {
const kubeConfig = new k8s.KubeConfig();
if (this.opts.runtimeEnv === "local") {
kubeConfig.loadFromDefault();
} else if (this.opts.runtimeEnv === "kubernetes") {
kubeConfig.loadFromCluster();
} else {
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
}
return {
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
kubeConfig: kubeConfig,
};
}
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
if (typeof candidate !== "object" || candidate === null) {
return false;
} else {
return true;
}
}
#logK8sError(err: unknown, debugOnly = false) {
if (debugOnly) {
this.logger.debug("K8s API Error", err);
} else {
this.logger.error("K8s API Error", err);
}
}
#handleK8sError(err: unknown) {
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
this.#logK8sError(err);
return;
}
this.#logK8sError(err, true);
if (typeof err.body.message === "string") {
this.#logK8sError({ message: err.body.message });
return;
}
this.#logK8sError({ body: err.body });
}
async #deletePods(opts: {
namespace: string;
dryRun?: boolean;
fieldSelector?: string;
labelSelector?: string;
}) {
return await this.k8sClient.core
.deleteCollectionNamespacedPod(
opts.namespace,
undefined, // pretty
undefined, // continue
opts.dryRun ? "All" : undefined,
opts.fieldSelector,
undefined, // gracePeriodSeconds
opts.labelSelector
)
.catch(this.#handleK8sError.bind(this));
}
async #deleteCompletedRuns() {
this.logger.log("Deleting completed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Succeeded",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
}
async #deleteFailedRuns() {
this.logger.log("Deleting failed runs");
const start = Date.now();
const result = await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=Failed",
labelSelector: "app=task-run",
});
const elapsedMs = Date.now() - start;
if (!result) {
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
return;
}
const total = (result.response as any)?.body?.items?.length ?? 0;
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
}
async #deleteUnrecoverableRuns() {
await this.#deletePods({
namespace: this.namespace,
fieldSelector: "status.phase=?",
labelSelector: "app=task-run",
});
}
async start() {
this.enabled = true;
this.logger.log("Starting");
const completedInterval = setInterval(async () => {
if (!this.enabled) {
clearInterval(completedInterval);
return;
}
try {
await this.#deleteCompletedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
}, this.intervalInSeconds * 1000);
const failedInterval = setInterval(
async () => {
if (!this.enabled) {
clearInterval(failedInterval);
return;
}
try {
await this.#deleteFailedRuns();
} catch (error) {
this.logger.error("Error deleting completed runs", error);
}
},
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
2 * this.intervalInSeconds * 1000
);
// this.#launchTests();
}
async stop() {
if (!this.enabled) {
return;
}
this.enabled = false;
this.logger.log("Shutting down..");
}
async #launchTests() {
const createPod = async (
container: k8s.V1Container,
name: string,
labels?: Record<string, string>
) => {
this.logger.log("Creating pod:", name);
const pod = {
metadata: {
name,
labels,
},
spec: {
restartPolicy: "Never",
automountServiceAccountToken: false,
terminationGracePeriodSeconds: 1,
containers: [container],
},
} satisfies k8s.V1Pod;
await this.k8sClient.core
.createNamespacedPod(this.namespace, pod)
.catch(this.#handleK8sError.bind(this));
};
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "sleep infinity"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "true"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
const container = {
name,
image: "docker.io/library/busybox",
command: ["sh"],
args: ["-c", "false"],
} satisfies k8s.V1Container;
await createPod(container, name, labels);
};
await createIdlePod("test-idle-1", { app: "task-run" });
await createFailedPod("test-failed-1", { app: "task-run" });
await createCompletedPod("test-completed-1", { app: "task-run" });
}
}
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
export class TaskMonitor {
#enabled = false;
#logger = new SimpleLogger("[TaskMonitor]");
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
#processedPods = new Map<string, number>();
#queue = new PQueue({ concurrency: 10 });
#k8sClient: {
core: k8s.CoreV1Api;
kubeConfig: k8s.KubeConfig;
@@ -44,6 +46,10 @@ export class TaskMonitor {
private labelSelector = "app in (task-index, task-run)";
constructor(private opts: TaskMonitorOptions) {
if (opts.namespace) {
this.namespace = opts.namespace;
}
this.#k8sClient = this.#createK8sClient();
this.#taskInformer = this.#createTaskInformer();
+19 -26
View File
@@ -1,10 +1,14 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
import { DiscordIcon, GitHubLightIcon } from "@trigger.dev/companyicons";
import { ActivityIcon } from "lucide-react";
import { ReactNode, useState } from "react";
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
import { cn } from "~/utils/cn";
import { docsPath } from "~/utils/pathBuilder";
import { Button, LinkButton } from "./primitives/Buttons";
import { Fieldset } from "./primitives/Fieldset";
import { FormButtons } from "./primitives/FormButtons";
@@ -13,20 +17,9 @@ import { Header1, Header2 } from "./primitives/Headers";
import { InputGroup } from "./primitives/InputGroup";
import { Label } from "./primitives/Label";
import { Paragraph } from "./primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "./primitives/Select";
import { Select, SelectItem } from "./primitives/Select";
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
import { TextArea } from "./primitives/TextArea";
import { cn } from "~/utils/cn";
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { ActivityIcon, HeartPulseIcon } from "lucide-react";
import { docsPath } from "~/utils/pathBuilder";
type FeedbackProps = {
button: ReactNode;
@@ -78,20 +71,20 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
<Fieldset className="max-w-full gap-y-3">
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
<InputGroup className="max-w-full">
<SelectGroup>
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
<SelectTrigger size="medium" width="full">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{Object.entries(feedbackTypeLabel).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<Select
{...conform.select(feedbackType)}
variant="tertiary/medium"
defaultValue={defaultValue}
placeholder="Select type"
text={(value) => feedbackTypeLabel[value]}
dropdownIcon
>
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
<SelectItem key={name} value={name}>
{title}
</SelectItem>
))}
</Select>
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
</InputGroup>
<InputGroup className="max-w-full">
@@ -290,6 +290,7 @@ export function TierPro({
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
fullWidth
value={concurrentBracketCode}
variant="primary"
onChange={(v) => setConcurrentBracketCode(v)}
/>
<div className="py-6">
@@ -1,5 +1,7 @@
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { cn } from "~/utils/cn";
import { sortEnvironments } from "~/utils/environmentSort";
import { SimpleTooltip } from "../primitives/Tooltip";
type Environment = Pick<RuntimeEnvironment, "type">;
const variants = {
@@ -33,6 +35,74 @@ export function EnvironmentLabel({
);
}
type EnvironmentWithUsername = Environment & { userName?: string };
export function EnvironmentLabels({
environments,
size = "small",
className,
}: {
environments: EnvironmentWithUsername[];
size?: keyof typeof variants;
className?: string;
}) {
const devEnvironments = sortEnvironments(
environments.filter((env) => env.type === "DEVELOPMENT")
);
const firstDevEnvironment = devEnvironments[0];
const otherDevEnvironments = devEnvironments.slice(1);
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
return (
<div className={cn("flex items-baseline gap-2", className)}>
{firstDevEnvironment && (
<EnvironmentLabel
environment={firstDevEnvironment}
userName={firstDevEnvironment.userName}
size={size}
/>
)}
{otherDevEnvironments.length > 0 ? (
<SimpleTooltip
disableHoverableContent
button={
<span
className={cn(
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
environmentBorderClassName({ type: "DEVELOPMENT" }),
environmentTextClassName({ type: "DEVELOPMENT" }),
variants[size]
)}
>
+{otherDevEnvironments.length}
</span>
}
content={
<div className="flex gap-1 py-1">
{otherDevEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
}
/>
) : null}
{otherEnvironments.map((environment, index) => (
<EnvironmentLabel
key={index}
environment={environment}
userName={environment.userName}
size={size}
/>
))}
</div>
);
}
export function environmentTitle(environment: Environment, username?: string) {
switch (environment.type) {
case "PRODUCTION":
@@ -9,7 +9,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
} from "../primitives/SimpleSelect";
import { EventListSearchSchema } from "./EventStatuses";
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
@@ -41,7 +41,7 @@ export function PageBody({
<div
className={cn(
scrollable
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
: "overflow-hidden",
className
)}
@@ -3,6 +3,7 @@ import {
ArrowRightIcon,
ArrowRightOnRectangleIcon,
BeakerIcon,
BellAlertIcon,
ChartBarIcon,
ClockIcon,
CursorArrowRaysIcon,
@@ -45,6 +46,7 @@ import {
v3ApiKeysPath,
v3DeploymentsPath,
v3EnvironmentVariablesPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
v3RunsPath,
@@ -601,6 +603,13 @@ function V3ProjectSideMenu({
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
iconColor="text-red-500"
to={v3ProjectAlertsPath(organization, project)}
data-action="alerts"
/>
<SideMenuItem
name="Project settings"
icon="settings"
@@ -0,0 +1,56 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { ReactNode } from "react";
import { cn } from "~/utils/cn";
const variants = {
"tertiary/small": {
box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600",
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
},
"minimal/small": {
box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs",
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
},
};
type Variant = keyof typeof variants;
type AppliedFilterProps = {
label: ReactNode;
value: ReactNode;
removable?: boolean;
onRemove?: () => void;
variant?: Variant;
className?: string;
};
export function AppliedFilter({
label,
value,
removable = true,
onRemove,
variant = "tertiary/small",
className,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
<div className={cn("flex items-center transition", variantClassName.box, className)}>
<div className="flex items-center gap-0.5">
<div className="text-text-dimmed">
<span>{label}</span>:
</div>
<div className="text-text-bright">
<div>{value}</div>
</div>
</div>
{removable && (
<button
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
onClick={onRemove}
>
<XMarkIcon className="size-3.5" />
</button>
)}
</div>
);
}
@@ -48,11 +48,11 @@ const theme = {
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
},
secondary: {
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
button:
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
shortcut:
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
},
tertiary: {
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
@@ -49,12 +49,7 @@ export function DetailCell({
const variation = variations[variant];
return (
<div
className={cn(
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
className
)}
>
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
<IconInBox
icon={leadingIcon}
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
@@ -62,20 +57,14 @@ export function DetailCell({
<div className="flex flex-1 flex-col">
<Paragraph
variant={variation.label.variant}
className={cn(
"flex-1 text-left transition group-hover:text-text-bright",
variation.label.className
)}
className={cn("flex-1 text-left", variation.label.className)}
>
{label}
</Paragraph>
{description && (
<Paragraph
variant={variation.description.variant}
className={cn(
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
variation.description.className
)}
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
>
{description}
</Paragraph>
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import { cn } from "~/utils/cn";
const variants = {
primary: {
base: "bg-charcoal-700",
active: "text-text-bright hover:bg-charcoal-750/50",
},
secondary: {
base: "bg-charcoal-700/50",
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
},
};
type Variants = keyof typeof variants;
type Options = {
label: string;
value: string;
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
value?: string;
defaultValue?: string;
options: Options[];
variant?: Variants;
fullWidth?: boolean;
onChange?: (value: string) => void;
};
@@ -21,11 +35,18 @@ export default function SegmentedControl({
value,
defaultValue,
options,
variant = "secondary",
fullWidth,
onChange,
}: SegmentedControlProps) {
return (
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
<div
className={cn(
"flex h-10 rounded text-text-bright",
variants[variant].base,
fullWidth ? "w-full" : "w-fit"
)}
>
<RadioGroup
value={value}
defaultValue={defaultValue ?? options[0].value}
@@ -46,11 +67,11 @@ export default function SegmentedControl({
cn(
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
active
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
: "",
checked
? "text-text-bright"
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
? variants[variant].active
: "text-text-dimmed transition hover:text-text-bright"
)
}
>
@@ -60,12 +81,12 @@ export default function SegmentedControl({
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
</div>
{checked && (
{checked && variant === "primary" && (
<motion.div
layoutId={`segmented-control-${name}`}
transition={{ duration: 0.4, type: "spring" }}
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
></motion.div>
/>
)}
</div>
</>
+601 -120
View File
@@ -1,143 +1,624 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as Ariakit from "@ariakit/react";
import { SelectProps as AriaSelectProps } from "@ariakit/react";
import { SelectValue } from "@ariakit/react-core/select/select-value";
import { Link } from "@remix-run/react";
import * as React from "react";
import { Fragment, useMemo, useState } from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { ShortcutKey } from "./ShortcutKey";
import { ChevronDown } from "lucide-react";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
small: {
button: "h-6 rounded text-xs px-2 ",
},
medium: {
button: "h-8 rounded text-xs px-3 text-sm",
},
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
const style = {
tertiary: {
button:
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
},
minimal: {
button:
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
},
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
const variants = {
"tertiary/small": {
button: cn(sizes.small.button, style.tertiary.button),
},
"tertiary/medium": {
button: cn(sizes.medium.button, style.tertiary.button),
},
"minimal/small": {
button: cn(sizes.small.button, style.minimal.button),
},
"minimal/medium": {
button: cn(sizes.medium.button, style.minimal.button),
},
};
type Variant = keyof typeof variants;
type Section<TItem> = {
type: "section";
title?: string;
items: TItem[];
};
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
const firstItem = data[0];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
(firstItem as Section<TItem>).type === "section" &&
(firstItem as Section<TItem>).items !== undefined &&
Array.isArray((firstItem as Section<TItem>).items)
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
export interface SelectProps<TValue extends string | string[], TItem>
extends Omit<Ariakit.SelectProps, "children"> {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
value?: Ariakit.SelectProviderProps<TValue>["value"];
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
label?: string | Ariakit.SelectLabelProps["render"];
heading?: string;
showHeading?: boolean;
items?: TItem[] | Section<TItem>[];
empty?: React.ReactNode;
filter?: (item: ItemFromSection<TItem>, search: string, title?: string) => boolean;
children:
| React.ReactNode
| ((
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: {
title?: string;
startIndex: number;
count: number;
};
}
) => React.ReactNode);
variant?: Variant;
open?: boolean;
setOpen?: (open: boolean) => void;
shortcut?: ShortcutDefinition;
allowItemShortcuts?: boolean;
clearSearchOnSelection?: boolean;
dropdownIcon?: boolean | React.ReactNode;
}
export function Select<TValue extends string | string[], TItem>({
children,
icon,
text,
placeholder,
value,
setValue,
defaultValue,
label,
heading,
showHeading = false,
items,
filter,
empty = null,
variant = "tertiary/small",
open,
setOpen,
shortcut,
allowItemShortcuts = true,
disabled,
clearSearchOnSelection = true,
dropdownIcon,
...props
}: SelectProps<TValue, TItem>) {
const [searchValue, setSearchValue] = useState("");
const searchable = items !== undefined && filter !== undefined;
const matches = useMemo(() => {
if (!items) return [];
if (!searchValue || !filter) return items;
if (isSection(items)) {
return items
.map((section) => ({
...section,
items: section.items.filter((item) =>
filter(item as ItemFromSection<TItem>, searchValue, section.title)
),
}))
.filter((section) => section.items.length > 0);
}
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
}, [searchValue, items]);
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
const select = (
<SelectProvider
open={open}
setOpen={setOpen}
virtualFocus={searchable}
value={value}
setValue={(v) => {
if (clearSearchOnSelection) {
setSearchValue("");
}
if (setValue) {
setValue(v as any);
}
}}
defaultValue={defaultValue}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
<SelectTrigger
icon={icon}
variant={variant}
text={text}
placeholder={placeholder}
shortcut={shortcut}
tooltipTitle={heading}
disabled={disabled}
dropdownIcon={dropdownIcon}
{...props}
/>
<SelectPopover>
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
<SelectList>
{typeof children === "function" ? (
matches.length > 0 ? (
isSection(matches) ? (
<SelectGroupedRenderer
items={matches}
children={children}
enableItemShortcuts={enableItemShortcuts}
/>
) : (
children(matches as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
})
)
) : (
empty
)
) : (
children
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
if (searchable) {
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
React.startTransition(() => {
setSearchValue(value);
});
}}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
{select}
</ComboboxProvider>
);
}
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
return select;
}
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
export interface SelectTriggerProps<TValue = any> extends AriaSelectProps {
icon?: React.ReactNode;
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
placeholder?: React.ReactNode;
variant?: Variant;
shortcut?: ShortcutDefinition;
tooltipTitle?: string;
dropdownIcon?: boolean | React.ReactNode;
}
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
export function SelectTrigger({
icon,
variant = "tertiary/small",
text,
shortcut,
tooltipTitle,
disabled,
placeholder,
dropdownIcon = false,
children,
className,
...props
}: SelectTriggerProps) {
const ref = React.useRef<HTMLButtonElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled,
});
const showTooltip = tooltipTitle || shortcut;
const variantClasses = variants[variant];
let content: React.ReactNode = "";
if (children) {
content = children;
} else if (text !== undefined) {
if (typeof text === "function") {
content = <SelectValue>{(value) => <>{text(value) ?? placeholder}</>}</SelectValue>;
} else {
content = text;
}
} else {
content = (
<SelectValue>
{(value) => (
<>
{typeof value === "string"
? value ?? placeholder
: value.length === 0
? placeholder
: value.join(", ")}
</>
)}
</SelectValue>
);
}
return (
<Ariakit.TooltipProvider timeout={200}>
<Ariakit.TooltipAnchor
className="button"
render={
<Ariakit.Select
className={cn(
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
variantClasses.button,
className
)}
ref={ref}
{...props}
/>
}
>
<div className="flex grow items-center gap-0.5">
{icon && <div className="-ml-1 flex-none">{icon}</div>}
<div className="truncate">{content}</div>
</div>
{dropdownIcon === true ? (
<ChevronDown
className={cn(
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
) : !dropdownIcon ? null : (
dropdownIcon
)}
</Ariakit.TooltipAnchor>
{showTooltip && (
<Ariakit.Tooltip
disabled={shortcut === undefined}
className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs"
>
<div className="flex items-center gap-2">
<span>{tooltipTitle ?? "Open menu"}</span>
{shortcut && (
<ShortcutKey
className={cn("size-4 flex-none")}
shortcut={shortcut}
variant={"small"}
/>
)}
</div>
</Ariakit.Tooltip>
)}
</Ariakit.TooltipProvider>
);
}
export interface SelectProviderProps<TValue extends string | string[]>
extends Ariakit.SelectProviderProps<TValue> {}
export function SelectProvider<TValue extends string | string[]>(
props: SelectProviderProps<TValue>
) {
return <Ariakit.SelectProvider {...props} />;
}
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
export function ComboboxProvider(props: ComboboxProviderProps) {
return <Ariakit.ComboboxProvider {...props} />;
}
function SelectGroupedRenderer<TItem>({
items,
children,
enableItemShortcuts,
}: {
items: Section<TItem>[];
children: (
items: ItemFromSection<TItem>[],
meta: {
shortcutsEnabled?: boolean;
section?: { title?: string; startIndex: number; count: number };
}
) => React.ReactNode;
enableItemShortcuts: boolean;
}) {
let count = 0;
return (
<>
{items.map((section, index) => {
const previousItem = items.at(index - 1);
count += previousItem ? previousItem.items.length : 0;
return (
<Fragment key={index}>
{children(section.items as ItemFromSection<TItem>[], {
shortcutsEnabled: enableItemShortcuts,
section: {
title: section.title,
startIndex: count - 1,
count: section.items.length,
},
})}
</Fragment>
);
})}
</>
);
}
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
export function SelectList(props: SelectListProps) {
const combobox = Ariakit.useComboboxContext();
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
return (
<Component
{...props}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
props.className
)}
/>
);
}
export interface SelectItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
}
const selectItemClasses =
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
export function SelectItem({
icon,
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
shortcut,
...props
}: SelectItemProps) {
const combobox = Ariakit.useComboboxContext();
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
const ref = React.useRef<HTMLDivElement>(null);
useShortcutKeys({
shortcut: shortcut,
action: (e) => {
e.preventDefault();
e.stopPropagation();
if (ref.current) {
ref.current.click();
}
},
disabled: props.disabled,
enabledOnInputElements: true,
});
return (
<Ariakit.SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(
selectItemClasses,
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
props.className
)}
ref={ref}
>
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
{icon}
<div className="grow truncate">{props.children || props.value}</div>
{checkIcon}
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
</Ariakit.SelectItem>
);
}
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
to: string;
}
export function SelectLinkItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
to,
...props
}: SelectLinkItemProps) {
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
icon?: React.ReactNode;
checkIcon?: React.ReactNode;
shortcut?: ShortcutDefinition;
onClick: React.ComponentProps<"button">["onClick"];
}
export function SelectButtonItem({
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
onClick,
...props
}: SelectButtonItemProps) {
const render = (
<button
onClick={onClick}
className={cn("block w-full text-left", selectItemClasses, props.className)}
/>
);
return (
<SelectItem
{...props}
render={render}
blurOnHoverEnd={false}
className={cn(selectItemClasses, props.className)}
/>
);
}
export function shortcutFromIndex(
index: number,
meta: {
shortcutsEnabled?: boolean;
section?: { startIndex: number };
}
): ShortcutDefinition | undefined {
if (!meta.shortcutsEnabled) return;
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
if (adjustedIndex > 9) return;
if (adjustedIndex === 9) {
adjustedIndex = -1;
}
return { key: String(adjustedIndex + 1) };
}
export interface SelectSeparatorProps extends React.ComponentProps<"div"> {}
export function SelectSeparator(props: SelectSeparatorProps) {
return <div {...props} className={cn("h-px bg-charcoal-700", props.className)} />;
}
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
export function SelectGroup(props: SelectGroupProps) {
return <Ariakit.SelectGroup {...props} />;
}
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
export function SelectGroupLabel(props: SelectGroupLabelProps) {
return (
<Ariakit.SelectGroupLabel
{...props}
className={cn(
"flex h-[1.375rem] items-center border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright",
props.className
)}
/>
);
}
export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
export function SelectHeading({ render, ...props }: SelectHeadingProps) {
return (
<div className="flex h-[1.375rem] flex-none cursor-default items-center gap-2 border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright">
<Ariakit.SelectHeading render={<>{render}</>} />
</div>
);
}
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
export function SelectPopover({
gutter = 5,
shift = 0,
unmountOnHide = true,
className,
...props
}: SelectPopoverProps) {
return (
<Ariakit.SelectPopover
gutter={gutter}
shift={shift}
unmountOnHide={unmountOnHide}
className={cn(
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
"max-w-[min(480px,var(--popover-available-width))]",
"max-h-[min(480px,var(--popover-available-height))]",
"origin-[var(--popover-transform-origin)]",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
/>
);
}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
export interface SelectLabelProps extends Ariakit.SelectLabelProps {}
//currently unstyled
export function SelectLabel(props: SelectLabelProps) {
return <Ariakit.SelectLabel {...props} />;
}
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export interface ComboBoxProps extends Ariakit.ComboboxProps {
shortcut?: ShortcutDefinition;
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
export function ComboBox({
autoSelect = true,
placeholder = "Filter options",
shortcut,
...props
}: ComboBoxProps) {
return (
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
<Ariakit.Combobox
autoSelect={autoSelect}
render={<input placeholder={placeholder} />}
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
{...props}
/>
{shortcut && (
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
)}
</div>
);
}
@@ -0,0 +1,143 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
const sizes = {
"secondary/small":
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
};
export type SelectProps = {
size?: keyof typeof sizes;
width?: "content" | "full";
};
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
const sizeClassName = sizes[size];
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
width === "full" ? "w-full" : "w-min",
sizeClassName,
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown
className={cn(
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
)}
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
});
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
position === "popper" && "translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"space-y-0.5 px-1 py-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn(
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
className
)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
contentClassName?: string;
};
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
({ className, children, contentClassName, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -20,7 +20,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "../primitives/Select";
} from "../primitives/SimpleSelect";
import { Spinner } from "../primitives/Spinner";
import {
FilterableEnvironment,
+642 -213
View File
@@ -1,9 +1,25 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import type { TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
import { RuntimeEnvironment, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { useCallback } from "react";
import * as Ariakit from "@ariakit/react";
import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
import { ListFilterIcon } from "lucide-react";
import { ReactNode, startTransition, useCallback, useMemo, useState } from "react";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
ComboboxProvider,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
Tooltip,
TooltipContent,
@@ -11,243 +27,656 @@ import {
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/Select";
import { TimeFrameFilter } from "../TimeFrameFilter";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "./TaskRunStatus";
export const allTaskRunStatuses = [
"PENDING",
"WAITING_FOR_DEPLOY",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"INTERRUPTED",
"SYSTEM_FAILURE",
"CRASHED",
] as TaskRunStatusType[];
TaskRunStatusCombo,
allTaskRunStatuses,
descriptionForTaskRunStatus,
runStatusTitle,
} from "./TaskRunStatus";
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus);
export const TaskRunListSearchFilters = z.object({
cursor: z.string().optional(),
direction: z.enum(["forward", "backward"]).optional(),
environments: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
tasks: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
versions: z
.string()
.optional()
.transform((value) => (value ? value.split(",") : undefined)),
environments: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
tasks: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
versions: z.preprocess(
(value) => (typeof value === "string" ? [value] : value),
z.string().array().optional()
),
statuses: z.preprocess(
(value) => (typeof value === "string" ? value.split(",") : undefined),
(value) => (typeof value === "string" ? [value] : value),
TaskAttemptStatus.array().optional()
),
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
from: z.coerce.number().optional(),
to: z.coerce.number().optional(),
});
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
const All = "ALL";
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
type RunFiltersProps = {
possibleEnvironments: DisplayableEnvironment[];
possibleTasks: string[];
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
hasFilters: boolean;
};
export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
const navigate = useNavigate();
export function RunsFilters(props: RunFiltersProps) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const { environments, tasks, versions, statuses, from, to } = TaskRunListSearchFilters.parse(
Object.fromEntries(searchParams.entries())
);
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
} else {
searchParams.delete(filterType);
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const handleStatusChange = useCallback((value: TaskRunAttemptStatus | typeof All) => {
handleFilterChange("statuses", value === "ALL" ? undefined : value);
}, []);
const handleTaskChange = useCallback((value: string | typeof All) => {
handleFilterChange("tasks", value === "ALL" ? undefined : value);
}, []);
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
handleFilterChange("environments", value === "ALL" ? undefined : value);
}, []);
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
if (range.from) {
searchParams.set("from", range.from.toString());
} else {
searchParams.delete("from");
}
if (range.to) {
searchParams.set("to", range.to.toString());
} else {
searchParams.delete("to");
}
searchParams.delete("cursor");
searchParams.delete("direction");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const clearFilters = useCallback(() => {
searchParams.delete("statuses");
searchParams.delete("environments");
searchParams.delete("tasks");
searchParams.delete("from");
searchParams.delete("to");
navigate(`${location.pathname}?${searchParams.toString()}`);
}, []);
const hasFilters =
searchParams.has("statuses") ||
searchParams.has("environments") ||
searchParams.has("tasks") ||
searchParams.has("period");
return (
<div className="flex flex-row justify-between">
<SelectGroup>
<Select
name="environment"
value={environments?.at(0) ?? "ALL"}
onValueChange={handleEnvironmentChange}
>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All environments
</Paragraph>
</SelectItem>
{possibleEnvironments.map((env) => (
<SelectItem key={env.id} value={env.id}>
<div className="flex items-center gap-x-2">
<EnvironmentLabel environment={env} userName={env.userName} />
<Paragraph variant="extra-small">environment</Paragraph>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="status" value={statuses?.at(0) ?? "ALL"} onValueChange={handleStatusChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select status" className="ml-2 p-0">
{statuses?.at(0) ? (
<TaskRunStatusCombo
status={statuses[0]}
className="text-xs"
iconClassName="animate-none"
/>
) : (
"All statuses"
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="overflow-visible">
<SelectItem value={"ALL"} className="">
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All statuses
</Paragraph>
</SelectItem>
{allTaskRunStatuses.map((status) => (
<TooltipProvider key={status}>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<SelectItem value={status} className="">
<TaskRunStatusCombo
status={status}
className="text-xs"
iconClassName="animate-none"
/>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForTaskRunStatus(status)}
</Paragraph>
</TooltipContent>
</SelectItem>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
))}
</SelectContent>
</Select>
</SelectGroup>
<SelectGroup>
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
<SelectTrigger size="minimal" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"ALL"}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
All tasks
</Paragraph>
</SelectItem>
{possibleTasks
.sort((a, b) => a.localeCompare(b)) // 🔤
.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
<div className="flex flex-row flex-wrap items-center gap-1">
<FilterMenu {...props} />
<AppliedFilters {...props} />
{hasFilters && (
<Form>
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
Clear all
</Button>
</Form>
)}
</div>
);
}
const filterTypes = [
{
name: "statuses",
title: "Status",
icon: (
<div className="flex size-4 items-center justify-center">
<div className="size-3 rounded-full border-2 border-text-dimmed" />
</div>
),
},
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
];
type FilterType = (typeof filterTypes)[number]["name"];
const shortcut = { key: "f" };
function FilterMenu(props: RunFiltersProps) {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const filterTrigger = (
<SelectTrigger
icon={
<div className="flex size-4 items-center justify-center">
<ListFilterIcon className="size-3.5" />
</div>
}
variant={"minimal/small"}
shortcut={shortcut}
tooltipTitle={"Filter runs"}
>
Filter
</SelectTrigger>
);
return (
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
{(search, setSearch) => (
<Menu
searchValue={search}
clearSearchValue={() => setSearch("")}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
{...props}
/>
)}
</FilterMenuProvider>
);
}
function FilterMenuProvider({
children,
onClose,
}: {
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
onClose?: () => void;
}) {
const [searchValue, setSearchValue] = useState("");
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open && onClose) {
onClose();
}
}}
>
{children(searchValue, setSearchValue)}
</ComboboxProvider>
);
}
function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
return (
<>
<AppliedStatusFilter />
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
<AppliedTaskFilter possibleTasks={possibleTasks} />
<AppliedPeriodFilter />
</>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
} & RunFiltersProps;
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "statuses":
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "environments":
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "tasks":
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
case "created":
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
}
}
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allTaskRunStatuses.map((status) => ({
title: runStatusTitle(status),
value: status,
}));
function StatusDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ statuses: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="group flex w-full flex-col py-0">
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
</TooltipTrigger>
<TooltipContent side="right" sideOffset={9}>
<Paragraph variant="extra-small">
{descriptionForTaskRunStatus(item.value)}
</Paragraph>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedStatusFilter() {
const { values, del } = useSearchParams();
const statuses = values("statuses");
if (statuses.length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<StatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Status"
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
onRemove={() => del(["statuses", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function EnvironmentsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleEnvironments,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleEnvironments: DisplayableEnvironment[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ environments: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleEnvironments.filter((item) => {
const title = environmentTitle(item, item.userName);
return title.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleEnvironments]);
return (
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.id}
value={item.id}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} userName={item.userName} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedEnvironmentFilter({
possibleEnvironments,
}: Pick<RunFiltersProps, "possibleEnvironments">) {
const { values, del } = useSearchParams();
if (values("environments").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<EnvironmentsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Environment"
value={appliedSummary(
values("environments").map((v) => {
const environment = possibleEnvironments.find((env) => env.id === v);
return environment ? environmentTitle(environment, environment.userName) : v;
})
)}
onRemove={() => del(["environments", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleEnvironments={possibleEnvironments}
/>
)}
</FilterMenuProvider>
);
}
function TasksDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleTasks,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({ tasks: values, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return possibleTasks.filter((item) => {
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleTasks]);
return (
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.slug}
value={item.slug}
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
>
{item.slug}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
const { values, del } = useSearchParams();
if (values("tasks").length === 0) {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<TasksDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Task"
value={appliedSummary(
values("tasks").map((v) => {
const task = possibleTasks.find((task) => task.slug === v);
return task ? task.slug : v;
})
)}
onRemove={() => del(["tasks", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleTasks={possibleTasks}
/>
)}
</FilterMenuProvider>
);
}
const timePeriods = [
{
label: "All periods",
value: "all",
},
{
label: "5 mins ago",
value: "5m",
},
{
label: "15 mins ago",
value: "15m",
},
{
label: "30 mins ago",
value: "30m",
},
{
label: "1 hour ago",
value: "1h",
},
{
label: "3 hours ago",
value: "3h",
},
{
label: "6 hours ago",
value: "6h",
},
{
label: "1 day ago",
value: "1d",
},
{
label: "3 days ago",
value: "3d",
},
{
label: "7 days ago",
value: "7d",
},
{
label: "10 days ago",
value: "10d",
},
{
label: "14 days ago",
value: "14d",
},
{
label: "30 days ago",
value: "30d",
},
];
function CreatedDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { value, replace } = useSearchParams();
const handleChange = (newValue: string) => {
clearSearchValue();
if (newValue === "all") {
if (!value) return;
}
replace({ period: newValue, cursor: undefined, direction: undefined });
};
const filtered = useMemo(() => {
return timePeriods.filter((item) =>
item.label.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
hideOnEnter={false}
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
<SelectList>
{filtered.map((item) => (
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
{item.label}
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function AppliedPeriodFilter() {
const { value, del } = useSearchParams();
if (value("period") === undefined || value("period") === "all") {
return null;
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<CreatedDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer" />}>
<AppliedFilter
label="Created"
value={
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
}
onRemove={() => del(["period", "cursor", "direction"])}
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function appliedSummary(values: string[], maxValues = 3) {
if (values.length === 0) {
return null;
}
if (values.length > maxValues) {
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
}
return values.join(", ");
}
@@ -1,9 +1,11 @@
import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { RuntimeEnvironment } from "@trigger.dev/database";
import { useCallback } from "react";
import { z } from "zod";
import { Input } from "~/components/primitives/Input";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useThrottle } from "~/hooks/useThrottle";
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
import { Button } from "../../primitives/Buttons";
import { Paragraph } from "../../primitives/Paragraph";
@@ -14,10 +16,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "../../primitives/Select";
import { Input } from "~/components/primitives/Input";
import { useDebounce } from "~/hooks/useDebounce";
import { useThrottle } from "~/hooks/useThrottle";
} from "../../primitives/SimpleSelect";
export const ScheduleListFilters = z.object({
page: z.coerce.number().default(1),
@@ -53,6 +52,9 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
Object.fromEntries(searchParams.entries())
);
const hasFilters =
searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search");
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
if (value) {
searchParams.set(filterType, value);
@@ -146,7 +148,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
@@ -156,7 +158,11 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
</Select>
</SelectGroup>
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
{hasFilters && (
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon}>
Clear all
</Button>
)}
</div>
);
}
@@ -15,6 +15,20 @@ import { SnowflakeIcon } from "lucide-react";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export const allTaskRunStatuses = [
"WAITING_FOR_DEPLOY",
"PENDING",
"EXECUTING",
"RETRYING_AFTER_FAILURE",
"WAITING_TO_RESUME",
"COMPLETED_SUCCESSFULLY",
"CANCELED",
"COMPLETED_WITH_ERRORS",
"CRASHED",
"INTERRUPTED",
"SYSTEM_FAILURE",
] as TaskRunStatus[];
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
PENDING: "Task is waiting to be executed",
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
+4
View File
@@ -174,6 +174,10 @@ Worker.init().catch((error) => {
function logError(error: unknown, request?: Request) {
console.error(error);
if (error instanceof Error && error.message.startsWith("There are locked jobs present")) {
console.log("⚠️ graphile-worker migration issue detected!");
}
}
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
+3
View File
@@ -153,6 +153,9 @@ const EnvironmentSchema = z.object({
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
});
export type Environment = z.infer<typeof EnvironmentSchema>;
+43
View File
@@ -0,0 +1,43 @@
import { useTextFilter } from "./useTextFilter";
type Task = {
id: string;
friendlyId: string;
taskIdentifier: string;
exportName: string;
filePath: string;
triggerSource: string;
};
export function useFilterTasks<T extends Task>({ tasks }: { tasks: T[] }) {
return useTextFilter<T>({
items: tasks,
filter: (task, text) => {
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.id.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
return true;
}
return false;
},
});
}
+76
View File
@@ -0,0 +1,76 @@
import { useNavigate } from "@remix-run/react";
import { useOptimisticLocation } from "./useOptimisticLocation";
import { useCallback } from "react";
type Values = Record<string, string | string[] | undefined>;
export function useSearchParams() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const search = new URLSearchParams(location.search);
const set = useCallback(
(values: Values) => {
for (const [param, value] of Object.entries(values)) {
if (value === undefined) {
search.delete(param);
continue;
}
if (typeof value === "string") {
search.set(param, value);
continue;
}
search.delete(param);
for (const v of value) {
search.append(param, v);
}
}
},
[location, search]
);
const replace = useCallback(
(values: Values) => {
set(values);
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
},
[location, search]
);
const del = useCallback(
(keys: string | string[]) => {
if (!Array.isArray(keys)) {
keys = [keys];
}
for (const key of keys) {
search.delete(key);
}
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
},
[location, search]
);
const value = useCallback(
(param: string) => {
return search.get(param) ?? undefined;
},
[location, search]
);
const values = useCallback(
(param: string) => {
return search.getAll(param);
},
[location, search]
);
return {
value,
values,
set,
replace,
del,
};
}
+16 -6
View File
@@ -17,16 +17,22 @@ export type ShortcutDefinition =
| Shortcut;
type useShortcutKeysProps = {
shortcut: ShortcutDefinition;
shortcut: ShortcutDefinition | undefined;
action: (event: KeyboardEvent) => void;
disabled?: boolean;
enabledOnInputElements?: boolean;
};
export function useShortcutKeys({ shortcut, action, disabled = false }: useShortcutKeysProps) {
export function useShortcutKeys({
shortcut,
action,
disabled = false,
enabledOnInputElements,
}: useShortcutKeysProps) {
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
const relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const relevantShortcut =
shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const keys = createKeysFromShortcut(relevantShortcut);
useHotkeys(
@@ -36,13 +42,17 @@ export function useShortcutKeys({ shortcut, action, disabled = false }: useShort
},
{
enabled: !disabled,
enableOnFormTags: relevantShortcut.enabledOnInputElements,
enableOnContentEditable: relevantShortcut.enabledOnInputElements,
enableOnFormTags: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
enableOnContentEditable: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
}
);
}
function createKeysFromShortcut(shortcut: Shortcut) {
function createKeysFromShortcut(shortcut: Shortcut | undefined) {
if (!shortcut) {
return [];
}
const modifiers = shortcut.modifiers;
const character = shortcut.key;
@@ -0,0 +1,229 @@
import { WebClient } from "@slack/web-api";
import {
IntegrationService,
Organization,
OrganizationIntegration,
SecretReference,
} from "@trigger.dev/database";
import { z } from "zod";
import { $transaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
const SlackSecretSchema = z.object({
botAccessToken: z.string(),
userAccessToken: z.string().optional(),
expiresIn: z.number().optional(),
refreshToken: z.string().optional(),
botScopes: z.array(z.string()).optional(),
userScopes: z.array(z.string()).optional(),
raw: z.record(z.any()).optional(),
});
type SlackSecret = z.infer<typeof SlackSecretSchema>;
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
AuthenticatableIntegration,
"service"
> & {
service: TService;
};
type AuthenticatedClientOptions<TService extends IntegrationService> = TService extends "SLACK"
? {
forceBotToken?: boolean;
}
: undefined;
type AuthenticatedClientForIntegration<TService extends IntegrationService> =
TService extends "SLACK" ? InstanceType<typeof WebClient> : never;
export type AuthenticatableIntegration = OrganizationIntegration & {
tokenReference: SecretReference;
};
export class OrgIntegrationRepository {
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
integration: OrganizationIntegrationForService<TService>,
options?: AuthenticatedClientOptions<TService>
): Promise<AuthenticatedClientForIntegration<TService>> {
const secretStore = getSecretStore(integration.tokenReference.provider);
switch (integration.service) {
case "SLACK": {
const secret = await secretStore.getSecret(
SlackSecretSchema,
integration.tokenReference.key
);
if (!secret) {
throw new Error("Failed to get access token");
}
// TODO refresh access token here
return new WebClient(
options?.forceBotToken
? secret.botAccessToken
: secret.userAccessToken ?? secret.botAccessToken
) as AuthenticatedClientForIntegration<TService>;
}
default: {
throw new Error(`Unsupported service ${integration.service}`);
}
}
}
static isSlackSupported =
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
static slackAuthorizationUrl(
state: string,
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
) {
return `https://slack.com/oauth/v2/authorize?client_id=${
env.ORG_SLACK_INTEGRATION_CLIENT_ID
}&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${
env.APP_ORIGIN
}/integrations/slack/callback`;
}
static async redirectToAuthService(
service: IntegrationService,
state: string,
request: Request,
redirectTo: string
) {
const session = await getUserSession(request);
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
if (!authUrl) {
throw new Response("Unsupported service", { status: 400 });
}
logger.debug("Redirecting to auth service", {
service,
authUrl,
redirectTo,
});
return new Response(null, {
status: 302,
headers: {
location: authUrl,
"Set-Cookie": await commitSession(session),
},
});
}
static async redirectAfterAuth(request: Request) {
const session = await getUserSession(request);
logger.debug("Redirecting back after auth", {
sessionData: session.data,
});
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
if (!redirectTo) {
throw new Response("Invalid redirect", { status: 400 });
}
session.unset(REDIRECT_AFTER_AUTH_KEY);
return new Response(null, {
status: 302,
headers: {
location: redirectTo,
"Set-Cookie": await commitSession(session),
},
});
}
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
switch (serviceName) {
case "slack": {
if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) {
throw new Error("Slack integration not configured");
}
const client = new WebClient();
const result = await client.oauth.v2.access({
client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID,
client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET,
code,
redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`,
});
if (result.ok) {
logger.debug("Received slack access token", {
result,
});
if (!result.access_token) {
throw new Error("Failed to get access token");
}
return await $transaction(prisma, async (tx) => {
const secretStore = getSecretStore("DATABASE", {
prismaClient: tx,
});
const integrationFriendlyId = generateFriendlyId("org_integration");
const secretValue: SlackSecret = {
botAccessToken: result.access_token!,
userAccessToken: result.authed_user ? result.authed_user.access_token : undefined,
expiresIn: result.expires_in,
refreshToken: result.refresh_token,
botScopes: result.scope ? result.scope.split(",") : [],
userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [],
raw: result,
};
logger.debug("Setting secret", {
secretValue,
});
await secretStore.setSecret(integrationFriendlyId, secretValue);
const reference = await tx.secretReference.create({
data: {
provider: "DATABASE",
key: integrationFriendlyId,
},
});
return await tx.organizationIntegration.create({
data: {
friendlyId: integrationFriendlyId,
organizationId: org.id,
service: "SLACK",
tokenReferenceId: reference.id,
integrationData: {
team: result.team,
user: result.authed_user
? {
id: result.authed_user.id,
}
: undefined,
} as any,
},
});
});
}
}
default: {
throw new Error(`Service ${serviceName} not supported`);
}
}
}
}
+12
View File
@@ -110,3 +110,15 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us
},
});
}
export async function findProjectByRef(externalRef: string, userId: string) {
// Find the project scoped to the organization, making sure the user belongs to that org
return await prisma.project.findFirst({
where: {
externalRef,
organization: {
members: { some: { userId } },
},
},
});
}
@@ -0,0 +1,33 @@
import { z } from "zod";
import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
export const ProjectAlertWebhookProperties = z.object({
secret: EncryptedSecretValueSchema,
url: z.string(),
});
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
export const ProjectAlertEmailProperties = z.object({
email: z.string(),
});
export type ProjectAlertEmailProperties = z.infer<typeof ProjectAlertEmailProperties>;
export const DeleteProjectAlertChannel = z.object({
id: z.string(),
});
export const ProjectAlertSlackProperties = z.object({
channelId: z.string(),
channelName: z.string(),
integrationId: z.string().nullish(),
});
export type ProjectAlertSlackProperties = z.infer<typeof ProjectAlertSlackProperties>;
export const ProjectAlertSlackStorage = z.object({
message_ts: z.string(),
});
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
+62 -21
View File
@@ -1,20 +1,21 @@
import type {
CronItem,
CronItemOptions,
Job as GraphileJob,
DbJob as GraphileJob,
Runner as GraphileRunner,
JobHelpers,
RunnerOptions,
Task,
TaskList,
TaskSpec,
WorkerUtils,
} from "graphile-worker";
import { run as graphileRun, parseCronItems } from "graphile-worker";
import { run as graphileRun, makeWorkerUtils, parseCronItems } from "graphile-worker";
import { SpanKind, trace } from "@opentelemetry/api";
import omit from "lodash.omit";
import { z } from "zod";
import { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { $replica, PrismaClient, PrismaClientOrTransaction } from "~/db.server";
import { PgListenService } from "~/services/db/pgListen.server";
import { workerLogger as logger } from "~/services/logger.server";
import { flattenAttributes } from "@trigger.dev/core/v3";
@@ -34,8 +35,8 @@ const RawCronPayloadSchema = z.object({
const GraphileJobSchema = z.object({
id: z.coerce.string(),
queue_name: z.string().nullable(),
task_identifier: z.string(),
job_queue_id: z.number().nullable(),
task_id: z.number(),
payload: z.unknown(),
priority: z.number(),
run_at: z.coerce.date(),
@@ -72,7 +73,7 @@ type RecurringTaskPayload = {
export type ZodRecurringTasks = {
[key: string]: {
pattern: string;
match: string;
options?: CronItemOptions;
handler: (payload: RecurringTaskPayload, job: GraphileJob) => Promise<void>;
};
@@ -129,6 +130,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
#rateLimiter?: ZodWorkerRateLimiter;
#shutdownTimeoutInMs?: number;
#shuttingDown = false;
#workerUtils?: WorkerUtils;
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
this.#name = options.name;
@@ -158,6 +160,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const parsedCronItems = parseCronItems(this.#createCronItemsFromRecurringTasks());
this.#workerUtils = await makeWorkerUtils(this.#runnerOptions);
this.#runner = await graphileRun({
...this.#runnerOptions,
noHandleSignals: true,
@@ -188,7 +192,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
this.#logDebug("Detected incoming migration", { latestMigration });
if (latestMigration > 10) {
// already migrated past v0.14 - nothing to do
this.#logDebug("Already migrated past v0.14 - nothing to do", { latestMigration });
return;
}
@@ -263,6 +267,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
public async stop() {
await this.#runner?.stop();
await this.#workerUtils?.release();
}
public async enqueue<K extends keyof TMessageCatalog>(
@@ -442,12 +447,29 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return taskList;
}
async #getQueueName(queueId: number | null) {
if (queueId === null) {
return;
}
const schema = z.array(z.object({ queue_name: z.string() }));
const rawQueueNameResults = await $replica.$queryRawUnsafe(
`SELECT queue_name FROM ${this.graphileWorkerSchema}._private_job_queues WHERE id = $1`,
queueId
);
const queueNameResults = schema.parse(rawQueueNameResults);
return queueNameResults[0]?.queue_name;
}
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
await this.enqueue(helpers.job.task_identifier, payload, {
runAt: helpers.job.run_at,
queueName: helpers.job.queue_name ?? undefined,
runAt: new Date(Date.now() + 1000 * 10),
queueName: await this.#getQueueName(helpers.job.job_queue_id),
priority: helpers.job.priority,
jobKey: helpers.job.key ?? undefined,
flags: Object.keys(helpers.job.flags ?? []),
@@ -460,7 +482,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
if (this.#cleanup) {
cronItems.push({
pattern: this.#cleanup.frequencyExpression,
match: this.#cleanup.frequencyExpression,
identifier: CLEANUP_TASK_NAME,
task: CLEANUP_TASK_NAME,
options: this.#cleanup.taskOptions,
@@ -469,7 +491,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
if (this.#reporter) {
cronItems.push({
pattern: "50 * * * *", // Every hour at 50 minutes past the hour
match: "50 * * * *", // Every hour at 50 minutes past the hour
identifier: REPORTER_TASK_NAME,
task: REPORTER_TASK_NAME,
});
@@ -481,7 +503,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
for (const [key, task] of Object.entries(this.#recurringTasks)) {
const cronItem: CronItem = {
pattern: task.pattern,
match: task.match,
identifier: key,
task: key,
options: task.options,
@@ -529,7 +551,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
attributes: {
"job.task_identifier": job.task_identifier,
"job.id": job.id,
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
"job.priority": job.priority,
"job.run_at": job.run_at.toISOString(),
@@ -599,7 +621,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
attributes: {
"job.task_identifier": job.task_identifier,
"job.id": job.id,
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
"job.priority": job.priority,
"job.run_at": job.run_at.toISOString(),
@@ -638,6 +660,10 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
return;
}
if (!this.#workerUtils) {
throw new Error("WorkerUtils need to be initialized before running job cleanup.");
}
const job = helpers.job;
logger.debug("Received cleanup task", {
@@ -663,23 +689,38 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
payload,
});
const rawResults = await this.#prisma.$queryRawUnsafe(
`WITH rows AS (SELECT id FROM ${this.graphileWorkerSchema}.jobs WHERE run_at < $1 AND locked_at IS NULL AND max_attempts = attempts LIMIT $2 FOR UPDATE) DELETE FROM ${this.graphileWorkerSchema}.jobs WHERE id IN (SELECT id FROM rows) RETURNING id`,
const rawResults = await $replica.$queryRawUnsafe(
`SELECT id
FROM ${this.graphileWorkerSchema}.jobs
WHERE run_at < $1
AND locked_at IS NULL
AND max_attempts = attempts
LIMIT $2`,
expirationDate,
this.#cleanup.maxCount
);
const results = Array.isArray(rawResults) ? rawResults : [];
const results = z
.array(
z.object({
id: z.coerce.string(),
})
)
.parse(rawResults);
const completedJobs = await this.#workerUtils.completeJobs(results.map((job) => job.id));
logger.debug("Cleaned up old jobs", {
count: results.length,
found: results.length,
deleted: completedJobs.length,
expirationDate,
payload,
});
if (this.#reporter) {
await this.#reporter("cleanup_stats", {
count: results.length,
found: results.length,
deleted: completedJobs.length,
expirationDate,
ts: payload._cron.ts,
});
@@ -711,7 +752,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const schema = z.array(z.object({ count: z.coerce.number() }));
// Count the number of jobs that have been added since the startAt date and before the payload._cron.ts date
const rawAddedResults = await this.#prisma.$queryRawUnsafe(
const rawAddedResults = await $replica.$queryRawUnsafe(
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs WHERE created_at > $1 AND created_at < $2`,
startAt,
payload._cron.ts
@@ -720,7 +761,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
const addedCountResults = schema.parse(rawAddedResults)[0];
// Count the total number of jobs in the jobs table
const rawTotalResults = await this.#prisma.$queryRawUnsafe(
const rawTotalResults = await $replica.$queryRawUnsafe(
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs`
);
@@ -14,7 +14,7 @@ import {
IndexEndpointStats,
parseEndpointIndexStats,
} from "@trigger.dev/core";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export type Client = {
slug: string;
@@ -1,6 +1,6 @@
import { z } from "zod";
import { PrismaClient, prisma } from "~/db.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { projectPath } from "~/utils/pathBuilder";
@@ -2,7 +2,7 @@ import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ProjectPresenter {
#prismaClient: PrismaClient;
@@ -0,0 +1,73 @@
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { ProjectAlertChannel } from "@trigger.dev/database";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
export type AlertChannelListPresenterRecord =
AlertChannelListPresenterData["alertChannels"][number];
export type AlertChannelListPresenterAlertProperties = NonNullable<
AlertChannelListPresenterRecord["properties"]
>;
export class AlertChannelListPresenter extends BasePresenter {
public async call(projectId: string) {
logger.debug("AlertChannelListPresenter", { projectId });
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId,
},
orderBy: {
createdAt: "desc",
},
});
return {
alertChannels: await Promise.all(
alertChannels.map(async (alertChannel) => ({
...alertChannel,
properties: await this.#presentProperties(alertChannel),
}))
),
};
}
async #presentProperties(alertChannel: ProjectAlertChannel) {
if (!alertChannel.properties) {
return;
}
switch (alertChannel.type) {
case "WEBHOOK":
const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties);
const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret);
return {
type: "WEBHOOK" as const,
url: parsedProperties.url,
secret,
};
case "EMAIL":
return {
type: "EMAIL" as const,
...ProjectAlertEmailProperties.parse(alertChannel.properties),
};
case "SLACK": {
return {
type: "SLACK" as const,
...ProjectAlertSlackProperties.parse(alertChannel.properties),
};
}
default:
throw new Error(`Unsupported alert channel type: ${alertChannel.type}`);
}
}
}
@@ -0,0 +1,131 @@
import {
ProjectAlertChannel,
ProjectAlertChannelType,
ProjectAlertType,
} from "@trigger.dev/database";
import assertNever from "assert-never";
import { z } from "zod";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "deployment_success"]);
export type ApiAlertType = z.infer<typeof ApiAlertType>;
export const ApiAlertChannel = z.enum(["email", "webhook"]);
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
export const ApiAlertChannelData = z.object({
email: z.string().optional(),
url: z.string().optional(),
secret: z.string().optional(),
});
export type ApiAlertChannelData = z.infer<typeof ApiAlertChannelData>;
export const ApiCreateAlertChannel = z.object({
alertTypes: ApiAlertType.array(),
name: z.string(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
});
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
export const ApiAlertChannelObject = z.object({
id: z.string(),
name: z.string(),
alertTypes: ApiAlertType.array(),
channel: ApiAlertChannel,
channelData: ApiAlertChannelData,
deduplicationKey: z.string().optional(),
});
export type ApiAlertChannelObject = z.infer<typeof ApiAlertChannelObject>;
export class ApiAlertChannelPresenter {
public static async alertChannelToApi(
alertChannel: ProjectAlertChannel
): Promise<ApiAlertChannelObject> {
return {
id: alertChannel.friendlyId,
name: alertChannel.name,
alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)),
channel: this.alertChannelTypeToApi(alertChannel.type),
channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties),
deduplicationKey: alertChannel.userProvidedDeduplicationKey
? alertChannel.deduplicationKey
: undefined,
};
}
public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType {
switch (alertType) {
case "TASK_RUN_ATTEMPT":
return "attempt_failure";
case "DEPLOYMENT_FAILURE":
return "deployment_failure";
case "DEPLOYMENT_SUCCESS":
return "deployment_success";
default:
assertNever(alertType);
}
}
public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType {
switch (alertType) {
case "attempt_failure":
return "TASK_RUN_ATTEMPT";
case "deployment_failure":
return "DEPLOYMENT_FAILURE";
case "deployment_success":
return "DEPLOYMENT_SUCCESS";
default:
assertNever(alertType);
}
}
public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel {
switch (type) {
case "EMAIL":
return "email";
case "WEBHOOK":
return "webhook";
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
}
async function channelDataFromProperties(
type: ProjectAlertChannelType,
properties: ProjectAlertChannel["properties"]
): Promise<ApiAlertChannelData> {
if (!properties) {
return {};
}
switch (type) {
case "EMAIL":
return ProjectAlertEmailProperties.parse(properties);
case "WEBHOOK":
const { url, secret } = ProjectAlertWebhookProperties.parse(properties);
return {
url,
secret: await decryptSecret(env.ENCRYPTION_KEY, secret),
};
case "SLACK":
throw new Error("Slack channels are not supported");
default:
assertNever(type);
}
}
@@ -1,7 +1,7 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
export class ApiKeysPresenter {
#prismaClient: PrismaClient;
@@ -144,18 +144,18 @@ export class DeploymentPresenter {
userName: getUsername(deployment.environment.orgMember?.user),
},
deployedBy: deployment.triggeredBy,
errorData: this.#prepareErrorData(deployment.errorData),
sdkVersion: deployment.worker?.sdkVersion,
imageReference: deployment.imageReference,
externalBuildData:
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
projectId: deployment.projectId,
organizationId: project.organizationId,
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
},
};
}
#prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
if (!errorData) {
return;
}
@@ -1,7 +1,7 @@
import { PrismaClient, prisma } from "~/db.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
@@ -0,0 +1,104 @@
import {
AuthenticatableIntegration,
OrgIntegrationRepository,
} from "~/models/orgIntegration.server";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
import { WebClient } from "@slack/web-api";
export class NewAlertChannelPresenter extends BasePresenter {
public async call(projectId: string) {
const project = await this._prisma.project.findUniqueOrThrow({
where: {
id: projectId,
},
});
// Find the latest Slack integration
const slackIntegration = await this._prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
},
orderBy: {
createdAt: "desc",
},
include: {
tokenReference: true,
},
});
// If there is a slack integration, then we need to get a list of Slack Channels
if (slackIntegration) {
const channels = await getSlackChannelsForToken(slackIntegration);
return {
slack: {
status: "READY" as const,
channels,
integrationId: slackIntegration.id,
},
};
} else {
if (OrgIntegrationRepository.isSlackSupported) {
return {
slack: {
status: "NOT_CONFIGURED" as const,
},
};
} else {
return {
slack: {
status: "NOT_AVAILABLE" as const,
},
};
}
}
}
}
async function getSlackChannelsForToken(integration: AuthenticatableIntegration) {
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration);
const channels = await getAllSlackConversations(client);
logger.debug("Received a list of slack conversations", {
channels,
});
return (channels ?? [])
.filter((channel) => !channel.is_archived)
.filter((channel) => channel.is_channel)
.filter((channel) => !channel.is_ext_shared)
.filter((channel) => channel.unlinked === 0)
.filter((channel) => channel.num_members)
.sort((a, b) => a.name!.localeCompare(b.name!));
}
type Channels = Awaited<ReturnType<WebClient["conversations"]["list"]>>["channels"];
async function getSlackConversationsPage(client: WebClient, nextCursor?: string) {
return client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: true,
cursor: nextCursor,
});
}
async function getAllSlackConversations(client: WebClient) {
let nextCursor: string | undefined = undefined;
let channels: Channels = [];
do {
const response = await getSlackConversationsPage(client, nextCursor);
if (!response.ok) {
throw new Error(`Failed to get channels: ${response.error}`);
}
channels = channels.concat(response.channels ?? []);
nextCursor = response.response_metadata?.next_cursor;
} while (nextCursor);
return channels;
}
@@ -1,4 +1,5 @@
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
import parse from "parse-duration";
import { Direction } from "~/components/runs/RunStatuses";
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
import { sqlDatabaseSchema } from "~/db.server";
@@ -15,6 +16,7 @@ type RunListOptions = {
statuses?: TaskRunStatus[];
environments?: string[];
scheduleId?: string;
period?: string;
from?: number;
to?: number;
//pagination
@@ -38,6 +40,7 @@ export class RunListPresenter extends BasePresenter {
statuses,
environments,
scheduleId,
period,
from,
to,
direction = "forward",
@@ -47,10 +50,11 @@ export class RunListPresenter extends BasePresenter {
const hasStatusFilters = statuses && statuses.length > 0;
const hasFilters =
tasks !== undefined ||
versions !== undefined ||
(tasks !== undefined && tasks.length > 0) ||
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
environments !== undefined ||
(environments !== undefined && environments.length > 0) ||
(period !== undefined && period !== "all") ||
from !== undefined ||
to !== undefined;
@@ -90,6 +94,8 @@ export class RunListPresenter extends BasePresenter {
},
});
const periodMs = period ? parse(period) : undefined;
//get the runs
let runs = await this._replica.$queryRaw<
{
@@ -152,6 +158,11 @@ export class RunListPresenter extends BasePresenter {
: Prisma.empty
}
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
${
periodMs
? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
: Prisma.empty
}
${
from
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
@@ -224,7 +235,11 @@ export class RunListPresenter extends BasePresenter {
next,
previous,
},
possibleTasks: possibleTasks.map((task) => task.slug),
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => {
return a.slug.localeCompare(b.slug);
}),
filters: {
tasks: tasks || [],
versions: versions || [],
@@ -10,7 +10,7 @@ import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
import { User } from "~/models/user.server";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { logger } from "~/services/logger.server";
import { BasePresenter } from "./basePresenter.server";
@@ -1,7 +1,7 @@
import { TaskTriggerSource } from "@trigger.dev/database";
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
import { sortEnvironments } from "~/services/environmentSort.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { createSearchParams } from "~/utils/searchParams";
import { getUsername } from "~/utils/username";
@@ -27,7 +27,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
} from "~/components/primitives/SimpleSelect";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
type FirstEndpointSheetProps = {
@@ -25,7 +25,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
} from "~/components/primitives/SimpleSelect";
import { TextLink } from "~/components/primitives/TextLink";
import { runStatusClassNameColor, runStatusTitle } from "~/components/runs/RunStatuses";
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
@@ -11,7 +11,7 @@ import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/component
import { StepContentContainer } from "~/components/StepContentContainer";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
@@ -261,15 +261,7 @@ export default function Page() {
</Suspense>
</TableCell>
<TableCell to={path}>
<div className="space-x-2">
{task.environments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment}
userName={environment.userName}
/>
))}
</div>
<EnvironmentLabels environments={task.environments} />
</TableCell>
<TableCellChevron to={path} />
</TableRow>
@@ -0,0 +1,50 @@
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import {
redirectBackWithSuccessMessage,
redirectWithSuccessMessage,
} from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import { getUserSession } from "~/services/sessionStorage.server";
import {
ProjectParamSchema,
v3NewProjectAlertPath,
v3NewProjectAlertPathConnectToSlackPath,
} from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Find an integration for Slack for this org
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
},
});
if (integration) {
return redirectWithSuccessMessage(
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
request,
"Successfully connected your Slack workspace"
);
} else {
// Redirect to Slack
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
request,
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
);
}
}
@@ -0,0 +1,397 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/router";
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Checkbox } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
import {
CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
const FormSchema = z
.object({
alertTypes: z
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
.min(1)
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
channelValue: z.string().nonempty(),
integrationId: z.string().optional(),
})
.refine(
(value) =>
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
{
message: "Must be a valid email address",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
{
message: "Must be a valid URL",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "SLACK"
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
: true,
{
message: "Must select a Slack channel",
path: ["channelValue"],
}
);
function formDataToCreateAlertChannelOptions(
formData: z.infer<typeof FormSchema>
): CreateAlertChannelOptions {
switch (formData.type) {
case "WEBHOOK": {
return {
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "WEBHOOK",
url: formData.channelValue,
},
};
}
case "EMAIL": {
return {
name: `Email to ${formData.channelValue}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "EMAIL",
email: formData.channelValue,
},
};
}
case "SLACK": {
const [channelId, channelName] = formData.channelValue.split("/");
return {
name: `Slack message to ${channelName}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
channel: {
type: "SLACK",
channelId,
channelName,
integrationId: formData.integrationId,
},
};
}
}
}
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const presenter = new NewAlertChannelPresenter();
const results = await presenter.call(project.id);
const url = new URL(request.url);
const option = url.searchParams.get("option");
return typedjson({
...results,
option: option === "slack" ? ("SLACK" as const) : undefined,
});
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parse(formData, { schema: FormSchema });
if (!submission.value) {
return json(submission);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
submission.error.key = "Project not found";
return json(submission);
}
const service = new CreateAlertChannelService();
const alertChannel = await service.call(
project.externalRef,
userId,
formDataToCreateAlertChannelOptions(submission.value)
);
if (!alertChannel) {
submission.error.key = "Failed to create alert channel";
return json(submission);
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Created ${alertChannel.name} alert`
);
};
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { slack, option } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "create";
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
id: "create-alert",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
useEffect(() => {
setIsOpen(true);
}, []);
useEffect(() => {
if (navigation.state !== "idle") return;
if (lastSubmission !== undefined) return;
form.ref.current?.reset();
}, [navigation.state, lastSubmission]);
return (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3ProjectAlertsPath(organization, project));
}
}}
>
<DialogContent>
<DialogHeader>New alert</DialogHeader>
<Form method="post" {...form.props}>
<Fieldset className="mt-2">
<InputGroup fullWidth>
<SegmentedControl
{...conform.input(type)}
options={[
{ label: "Email", value: "EMAIL" },
{ label: "Slack", value: "SLACK" },
{ label: "Webhook", value: "WEBHOOK" },
]}
onChange={(value) => {
setCurrentAlertChannel(value);
}}
fullWidth
defaultValue={currentAlertChannel ?? undefined}
/>
</InputGroup>
{currentAlertChannel === "EMAIL" ? (
<InputGroup fullWidth>
<Label>Email</Label>
<Input
{...conform.input(channelValue)}
placeholder="email@youremail.com"
type="email"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
</InputGroup>
) : currentAlertChannel === "SLACK" ? (
<InputGroup fullWidth>
{slack.status === "READY" ? (
<>
<Select
{...conform.select(channelValue)}
placeholder="Select a Slack channel"
heading="Filter channels…"
defaultValue={undefined}
dropdownIcon
variant="tertiary/medium"
items={slack.channels}
filter={(channel, search) =>
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
}
text={(value) => {
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
if (!channel) return;
return <SlackChannelTitle {...channel} />;
}}
>
{(matches) => (
<>
{matches?.map((channel) => (
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
<SlackChannelTitle {...channel} />
</SelectItem>
))}
</>
)}
</Select>
<Hint className="leading-relaxed">
If selecting a private channel, you will need to invite the bot to the channel
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
</Hint>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
<input type="hidden" name="integrationId" value={slack.integrationId} />
</>
) : slack.status === "NOT_CONFIGURED" ? (
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
) : (
<Callout variant="warning">
Slack integration is not available. Please contact your organization
administrator.
</Callout>
)}
</InputGroup>
) : (
<InputGroup fullWidth>
<Label>URL</Label>
<Input
{...conform.input(channelValue)}
placeholder="https://foobar.com/webhooks"
type="url"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
</InputGroup>
)}
<InputGroup fullWidth>
<Label>Events</Label>
<Checkbox
name={alertTypes.name}
id="TASK_RUN_ATTEMPT"
value="TASK_RUN_ATTEMPT"
variant="simple/small"
label="Task run failure"
defaultChecked
/>
<Checkbox
name={alertTypes.name}
id="DEPLOYMENT_FAILURE"
value="DEPLOYMENT_FAILURE"
variant="simple/small"
label="Deployment failure"
defaultChecked
/>
<Checkbox
name={alertTypes.name}
id="DEPLOYMENT_SUCCESS"
value="DEPLOYMENT_SUCCESS"
variant="simple/small"
label="Deployment success"
defaultChecked
/>
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<div className="border-t border-grid-bright pt-3">
<FormButtons
confirmButton={
<Button
variant="primary/medium"
disabled={isLoading}
name="action"
value="create"
>
{isLoading ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</div>
</Fieldset>
</Form>
</DialogContent>
</Dialog>
);
}
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
return (
<div className="flex items-center gap-1.5">
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
<span>{name}</span>
</div>
);
}
@@ -0,0 +1,445 @@
import { useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import {
BoltIcon,
BoltSlashIcon,
BookOpenIcon,
EnvelopeIcon,
GlobeAltIcon,
LockClosedIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { prisma } from "~/db.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
AlertChannelListPresenter,
AlertChannelListPresenterRecord,
} from "~/presenters/v3/AlertChannelListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
v3NewProjectAlertPath,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const presenter = new AlertChannelListPresenter();
const data = await presenter.call(project.id);
return typedjson(data);
};
const schema = z.discriminatedUnion("action", [
z.object({ action: z.literal("delete"), id: z.string() }),
z.object({ action: z.literal("disable"), id: z.string() }),
z.object({ action: z.literal("enable"), id: z.string() }),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parse(formData, { schema });
if (!submission.value) {
return json(submission);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
submission.error.key = "Project not found";
return json(submission);
}
switch (submission.value.action) {
case "delete": {
const alertChannel = await prisma.projectAlertChannel.delete({
where: { id: submission.value.id, projectId: project.id },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Deleted ${alertChannel.name} alert`
);
}
case "disable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: false },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Disabled ${alertChannel.name} alert`
);
}
case "enable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: true },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Enabled ${alertChannel.name} alert`
);
}
}
};
export default function Page() {
const { alertChannels } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
return (
<PageContainer>
<NavBar>
<PageTitle title="Alerts" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/project-alerts")}
variant="minimal/small"
>
Alerts docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
<LinkButton
to={v3NewProjectAlertPath(organization, project)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert Types</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.length > 0 ? (
alertChannels.map((alertChannel) => (
<TableRow key={alertChannel.id}>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.name}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus enabled={alertChannel.enabled} />
</TableCell>
<TableCellMenu isSticky>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</TableCellMenu>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5}>
<div className="flex items-center justify-center">
<Paragraph>No alerts have been created</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Outlet />
</PageBody>
</PageContainer>
);
}
function DeleteAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "delete-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="delete"
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
className="text-xs"
>
{isLoading ? "Deleting" : "Delete"}
</Button>
</Form>
);
}
function DisableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "disable-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="disable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltSlashIcon}
leadingIconClassName="text-dimmed"
className="text-xs"
>
{isLoading ? "Disabling" : "Disable"}
</Button>
</Form>
);
}
function EnableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form, { id }] = useForm({
id: "enable-alert-channel",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="enable"
type="submit"
variant="small-menu-item"
LeadingIcon={BoltIcon}
leadingIconClassName="text-success"
className="text-xs"
>
{isLoading ? "Enabling" : "Enable"}
</Button>
</Form>
);
}
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
switch (alertChannel.properties?.type) {
case "EMAIL": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
}
leadingIconClassName="text-charcoal-400"
label={"Email"}
description={alertChannel.properties.email}
/>
);
}
case "WEBHOOK": {
return (
<DetailCell
leadingIcon={
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
</Tooltip>
</TooltipProvider>
}
leadingIconClassName="text-charcoal-400"
label={alertChannel.properties.url}
description={
<ClipboardField
value={alertChannel.properties.secret}
variant="secondary/small"
icon={<LockClosedIcon className="size-4" />}
iconButton
secure={"•".repeat(alertChannel.properties.secret.length)}
className="mt-1 w-80"
/>
}
/>
);
}
case "SLACK": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-charcoal-400"
/>
}
leadingIconClassName="text-charcoal-400"
label={"Slack"}
description={`#${alertChannel.properties.channelName}`}
/>
);
}
}
return null;
}
export function alertTypeTitle(alertType: ProjectAlertType): string {
switch (alertType) {
case "TASK_RUN_ATTEMPT":
return "Task attempt failure";
case "DEPLOYMENT_FAILURE":
return "Deployment failure";
case "DEPLOYMENT_SUCCESS":
return "Deployment success";
default: {
assertNever(alertType);
}
}
}
export function AlertChannelTypeIcon({
channelType,
className,
}: {
channelType: ProjectAlertChannelType;
className: string;
}) {
switch (channelType) {
case "EMAIL":
return <EnvelopeIcon className={className} />;
case "SLACK":
return <SlackIcon className={className} />;
case "WEBHOOK":
return <GlobeAltIcon className={className} />;
default: {
assertNever(channelType);
}
}
}
@@ -46,13 +46,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments } = await presenter.call({
const { environments } = await presenter.call({
userId,
projectSlug: projectParam,
});
return typedjson({
environmentVariables,
environments,
});
} catch (error) {
@@ -150,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
const { environments } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
@@ -30,8 +30,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
const url = new URL(request.url);
const s = Object.fromEntries(url.searchParams.entries());
const { tasks, versions, statuses, environments, from, to, cursor, direction } =
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
statuses: url.searchParams.getAll("statuses"),
environments: url.searchParams.getAll("environments"),
tasks: url.searchParams.getAll("tasks"),
period: url.searchParams.get("period") ?? undefined,
};
const { tasks, versions, statuses, environments, period, from, to, cursor, direction } =
TaskRunListSearchFilters.parse(s);
const presenter = new RunListPresenter();
@@ -42,6 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
versions,
statuses,
environments,
period,
from,
to,
direction: direction,
@@ -88,10 +96,11 @@ export default function Page() {
) : (
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
<div>
<div className="mb-2 flex items-center justify-between gap-x-2">
<div className="mb-2 flex items-start justify-between gap-x-2">
<RunsFilters
possibleEnvironments={project.environments}
possibleTasks={list.possibleTasks}
hasFilters={list.hasFilters}
/>
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={list} />
@@ -8,7 +8,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
@@ -211,16 +211,7 @@ export default function Page() {
</div>
</Property>
<Property label="Environments">
<div className="flex flex-wrap gap-1">
{schedule.environments.map((env) => (
<EnvironmentLabel
key={env.id}
size="small"
environment={env}
userName={env.userName}
/>
))}
</div>
<EnvironmentLabels size="small" environments={schedule.environments} />
</Property>
<Property label="External ID">
{schedule.externalId ? schedule.externalId : ""}
@@ -6,7 +6,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
@@ -281,15 +281,7 @@ function SchedulesTable({
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : ""}
</TableCell>
<TableCell to={path} className={cellClass}>
<div className="flex gap-1">
{schedule.environments.map((environment) => (
<EnvironmentLabel
key={environment.id}
environment={environment}
userName={environment.userName}
/>
))}
</div>
<EnvironmentLabels environments={schedule.environments} size="small" />
</TableCell>
<TableCell to={path}>
<EnabledStatus enabled={schedule.active} />
@@ -30,11 +30,11 @@ import {
} from "~/components/primitives/Table";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { useFilterTasks } from "~/hooks/useFilterTasks";
import { useLinkStatus } from "~/hooks/useLinkStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTextFilter } from "~/hooks/useTextFilter";
import {
SelectedEnvironment,
TaskListItem,
@@ -153,36 +153,7 @@ function TaskSelector({
tasks: TaskListItem[];
environmentSlug: string;
}) {
const { filterText, setFilterText, filteredItems } = useTextFilter<TaskListItem>({
items: tasks,
filter: (task, text) => {
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.id.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
return true;
}
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
return true;
}
return false;
},
});
const { filterText, setFilterText, filteredItems } = useFilterTasks<TaskListItem>({ tasks });
return (
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
@@ -16,14 +16,7 @@ import { FormTitle } from "~/components/primitives/FormTitle";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
import { Select, SelectItem } from "~/components/primitives/Select";
import { prisma } from "~/db.server";
import { useFeatures } from "~/hooks/useFeatures";
import { redirectWithSuccessMessage } from "~/models/message.server";
@@ -148,20 +141,24 @@ export default function NewOrganizationPage() {
{canCreateV3Projects ? (
<InputGroup>
<Label htmlFor={projectVersion.id}>Project version</Label>
<SelectGroup>
<Select
{...conform.input(projectVersion, { type: "select" })}
defaultValue={defaultVersion}
>
<SelectTrigger width="full" size="medium">
<SelectValue placeholder="Project version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="v2">Version 2</SelectItem>
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
</SelectContent>
</Select>
</SelectGroup>
<Select
{...conform.select(projectVersion)}
defaultValue={undefined}
variant="tertiary/medium"
placeholder="Select version"
dropdownIcon
text={(value) => {
switch (value) {
case "v2":
return "Version 2";
case "v3":
return "Version 3";
}
}}
>
<SelectItem value="v2">Version 2</SelectItem>
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
</Select>
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : (
+19 -19
View File
@@ -17,14 +17,7 @@ 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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
import { Select, SelectItem } from "~/components/primitives/Select";
import { featuresForRequest } from "~/features.server";
import { useFeatures } from "~/hooks/useFeatures";
import { createOrganization } from "~/models/organization.server";
@@ -143,17 +136,24 @@ export default function NewOrganizationPage() {
{v3Enabled ? (
<InputGroup>
<Label htmlFor={projectVersion.id}>Project version</Label>
<SelectGroup>
<Select {...conform.input(projectVersion, { type: "select" })} defaultValue={"v2"}>
<SelectTrigger width="full" size="medium">
<SelectValue placeholder="Project version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="v2">Version 2</SelectItem>
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
</SelectContent>
</Select>
</SelectGroup>
<Select
{...conform.select(projectVersion)}
defaultValue={undefined}
variant="tertiary/medium"
placeholder="Select version"
dropdownIcon
text={(value) => {
switch (value) {
case "v2":
return "Version 2";
case "v3":
return "Version 3";
}
}}
>
<SelectItem value="v2">Version 2</SelectItem>
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
</Select>
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
</InputGroup>
) : (
@@ -0,0 +1,94 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import {
ApiAlertChannelPresenter,
ApiCreateAlertChannel,
} from "~/presenters/v3/ApiAlertChannelPresenter.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const { projectRef } = parsedParams.data;
const rawBody = await request.json();
const body = ApiCreateAlertChannel.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const service = new CreateAlertChannelService();
try {
if (body.data.channel === "email") {
if (!body.data.channelData.email) {
return json({ error: "Email is required" }, { status: 422 });
}
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
name: body.data.name,
alertTypes: body.data.alertTypes.map((type) =>
ApiAlertChannelPresenter.alertTypeFromApi(type)
),
channel: {
type: "EMAIL",
email: body.data.channelData.email,
},
deduplicationKey: body.data.deduplicationKey,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
}
if (body.data.channel === "webhook") {
if (!body.data.channelData.url) {
return json({ error: "webhook url is required" }, { status: 422 });
}
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
name: body.data.name,
alertTypes: body.data.alertTypes.map((type) =>
ApiAlertChannelPresenter.alertTypeFromApi(type)
),
channel: {
type: "WEBHOOK",
url: body.data.channelData.url,
secret: body.data.channelData.secret,
},
deduplicationKey: body.data.deduplicationKey,
});
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
}
return json({ error: "Invalid channel type" }, { status: 422 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
@@ -0,0 +1,66 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import z from "zod";
import { redirectBackWithErrorMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { requireUserId } from "~/services/session.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { CreateOrgIntegrationService } from "~/v3/services/createOrgIntegration.server";
const URLSearchSchema = z
.object({
code: z.string().optional(),
state: z.string().optional(),
error: z.string().optional(),
})
.passthrough();
const ParamsSchema = z.object({
serviceName: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() !== "GET") {
return { status: 405, body: "Method Not Allowed" };
}
const userId = await requireUserId(request);
const url = requestUrl(request);
const parsedSearchParams = URLSearchSchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsedSearchParams.success) {
// TODO: this needs to lookup the redirect url in the cookies
throw new Response("Invalid params", { status: 400 });
}
if (parsedSearchParams.data.error) {
// TODO: this needs to lookup the redirect url in the cookies
throw new Response(parsedSearchParams.data.error, { status: 400 });
}
if (!parsedSearchParams.data.code || !parsedSearchParams.data.state) {
throw new Response("Invalid params", { status: 400 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
throw new Response("Invalid params", { status: 400 });
}
const service = new CreateOrgIntegrationService();
const integration = await service.call(
userId,
parsedSearchParams.data.state,
parsedParams.data.serviceName,
parsedSearchParams.data.code
);
if (integration) {
return await OrgIntegrationRepository.redirectAfterAuth(request);
}
return redirectBackWithErrorMessage(request, "Failed to connect to the service");
}
@@ -0,0 +1,5 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
export function action({ request }: ActionFunctionArgs) {
return new Response(null, { status: 200 });
}
@@ -21,14 +21,7 @@ import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/primitives/Select";
import { Select, SelectItem } from "~/components/primitives/Select";
import {
Table,
TableBody,
@@ -189,28 +182,25 @@ export function UpsertScheduleForm({
<Fieldset>
<InputGroup>
<Label htmlFor={taskIdentifier.id}>Task</Label>
<SelectGroup>
<Select
{...conform.input(taskIdentifier, { type: "select" })}
defaultValue={schedule?.taskIdentifier}
>
<SelectTrigger size="medium" width="full">
<SelectValue placeholder="Select task" className="ml-2 p-0" />
</SelectTrigger>
<SelectContent>
{possibleTasks.map((task) => (
<Select
{...conform.select(taskIdentifier)}
placeholder="Select a task"
defaultValue={schedule?.taskIdentifier}
heading={"Filter..."}
items={possibleTasks}
filter={(task, search) => task.toLowerCase().includes(search.toLowerCase())}
dropdownIcon
>
{(matches) => (
<>
{matches?.map((task) => (
<SelectItem key={task} value={task}>
<Paragraph
variant="extra-small"
className="pl-0.5 transition group-hover:text-text-bright"
>
{task}
</Paragraph>
{task}
</SelectItem>
))}
</SelectContent>
</Select>
</SelectGroup>
</>
)}
</Select>
<FormError id={taskIdentifier.errorId}>{taskIdentifier.error}</FormError>
</InputGroup>
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
@@ -278,10 +278,8 @@ export default function Story() {
<span className="text-charcoal-900">Continue with GitHub</span>
</Button>
<Button variant="secondary/large" fullWidth>
<EnvelopeIcon
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
/>
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
<span className="text-secondary">Continue with Email</span>
</Button>
<Button variant="tertiary/large" fullWidth>
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
@@ -308,10 +306,8 @@ export default function Story() {
<span className="text-charcoal-900">Continue with GitHub</span>
</Button>
<Button variant="secondary/extra-large" fullWidth>
<EnvelopeIcon
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
/>
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
<span className="text-secondary">Continue with Email</span>
</Button>
<Button variant="tertiary/extra-large" fullWidth>
<GitHubLightIcon className={"mr-1.5 h-5 w-5"} />
@@ -14,6 +14,13 @@ export default function Story() {
>
{isDisabled ? "Enable checkboxes" : "Disable checkboxes"}
</Button>
<Checkbox
name="Simple checkbox"
id="check1"
variant="simple/small"
label="This is a simple small checkbox"
disabled={isDisabled}
/>
<Checkbox
name="Simple checkbox"
id="check1"
@@ -0,0 +1,246 @@
import { CpuChipIcon } from "@heroicons/react/20/solid";
import { CircleStackIcon } from "@heroicons/react/24/outline";
import { Form } from "@remix-run/react";
import { startTransition, useCallback, useMemo, useState } from "react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import {
ComboBox,
ComboboxProvider,
SelectButtonItem,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
TaskRunStatusCombo,
allTaskRunStatuses,
runStatusTitle,
} from "~/components/runs/v3/TaskRunStatus";
import { useSearchParams } from "~/hooks/useSearchParam";
import { ShortcutDefinition } from "~/hooks/useShortcutKeys";
export default function Story() {
return (
<div className="flex h-full max-w-full flex-wrap items-start justify-start gap-2 px-8 py-16">
<Form className="space-y-4">
<div className="flex gap-16">
<Filter />
</div>
</Form>
</div>
);
}
const filterTypes = [
{
name: "status",
title: "Status",
icon: <CircleStackIcon className="h-4 w-4" />,
},
{ name: "environment", title: "Environment", icon: <CpuChipIcon className="h-4 w-4" /> },
];
type FilterType = (typeof filterTypes)[number]["name"];
//todo what if we had shortcut keys that would deeplink you to the appropriate filter?
function Filter() {
const [filterType, setFilterType] = useState<FilterType | undefined>();
const [searchValue, setSearchValue] = useState("");
const shortcut = { key: "f" };
const clearSearchValue = useCallback(() => {
setSearchValue("");
}, [setSearchValue]);
const filterTrigger = (
<SelectTrigger variant={"tertiary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"}>
Filter
</SelectTrigger>
);
return (
<ComboboxProvider
resetValueOnHide
setValue={(value) => {
startTransition(() => {
setSearchValue(value);
});
}}
setOpen={(open) => {
if (!open) {
setFilterType(undefined);
}
}}
>
<Menu
searchValue={searchValue}
clearSearchValue={clearSearchValue}
shortcut={shortcut}
trigger={filterTrigger}
filterType={filterType}
setFilterType={setFilterType}
/>
</ComboboxProvider>
);
}
type MenuProps = {
searchValue: string;
clearSearchValue: () => void;
shortcut: ShortcutDefinition;
trigger: React.ReactNode;
filterType: FilterType | undefined;
setFilterType: (filterType: FilterType | undefined) => void;
};
function Menu(props: MenuProps) {
switch (props.filterType) {
case undefined:
return <MainMenu {...props} />;
case "status":
return <Statuses {...props} />;
case "environment":
return <Environments {...props} />;
}
return <></>;
}
function MainMenu({ searchValue, clearSearchValue, setFilterType, trigger, shortcut }: MenuProps) {
const filtered = useMemo(() => {
return filterTypes.filter((item) =>
item.title.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider virtualFocus={true}>
{trigger}
<SelectPopover>
<ComboBox placeholder={"Filter..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((type, index) => (
<SelectButtonItem
key={type.name}
onClick={() => {
clearSearchValue();
setFilterType(type.name);
}}
icon={type.icon}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
{type.title}
</SelectButtonItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const statuses = allTaskRunStatuses.map((status) => ({
title: runStatusTitle(status),
value: status,
}));
function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) {
const { values, replace } = useSearchParams();
const handleChange = useCallback((values: string[]) => {
clearSearchValue();
replace({ status: values });
}, []);
const filtered = useMemo(() => {
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
}, [searchValue]);
return (
<SelectProvider value={values("status")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
hideOnEscape={() => {
setFilterType(undefined);
return false;
}}
>
<ComboBox placeholder={"Filter by status..."} shortcut={shortcut} value={searchValue} />
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
const environments = [
{
type: "DEVELOPMENT" as const,
},
{
type: "STAGING" as const,
},
{
type: "PRODUCTION" as const,
},
];
function Environments({
trigger,
clearSearchValue,
shortcut,
searchValue,
setFilterType,
}: MenuProps) {
const { values, replace } = useSearchParams();
const handleChange = useCallback((values: string[]) => {
clearSearchValue();
replace({ environment: values });
}, []);
const filtered = useMemo(() => {
return environments.filter((item) =>
item.type.toLowerCase().includes(searchValue.toLowerCase())
);
}, [searchValue]);
return (
<SelectProvider value={values("environment")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
hideOnEscape={() => {
setFilterType(undefined);
return false;
}}
>
<ComboBox
placeholder={"Filter by environment..."}
shortcut={shortcut}
value={searchValue}
/>
<SelectList>
{filtered.map((item, index) => (
<SelectItem
key={item.type}
value={item.type}
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
>
<EnvironmentLabel environment={item} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -1,4 +1,5 @@
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
const options = [
@@ -8,8 +9,15 @@ const options = [
export default function Story() {
return (
<MainCenteredContainer>
<SegmentedControl name="name" options={options} />
<MainCenteredContainer className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<Paragraph>Primary</Paragraph>
<SegmentedControl name="name" options={options} variant="primary" />
</div>
<div className="flex flex-col gap-2">
<Paragraph>Secondary</Paragraph>
<SegmentedControl name="name" options={options} variant="secondary" />
</div>
</MainCenteredContainer>
);
}
+257 -76
View File
@@ -1,91 +1,272 @@
import { Header1, Header2 } from "~/components/primitives/Headers";
import { CircleStackIcon } from "@heroicons/react/20/solid";
import { Form, useNavigate } from "@remix-run/react";
import { useCallback, useState } from "react";
import { LogoIcon } from "~/components/LogoIcon";
import { Button } from "~/components/primitives/Buttons";
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
SelectLinkItem,
shortcutFromIndex,
} from "~/components/primitives/Select";
import {
TaskRunStatusCombo,
allTaskRunStatuses,
runStatusTitle,
} from "~/components/runs/v3/TaskRunStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
const branches = [
"main",
"0.10-stable",
"0.11-stable",
"0.12-stable",
"0.13-stable",
"0.14-stable",
"15-stable",
"15.6-dev",
"16.3-dev",
"16.4.2-dev",
"16.8.3",
"16.8.4",
"16.8.5",
"16.8.6",
"17.0.0-dev",
"builds/facebook-www",
"devtools-v4-merge",
"fabric-cleanup",
"fabric-focus-blur",
"gh-pages",
"leg",
"nativefb-enable-cache",
"nov-main-trigger",
"rsckeys",
];
export default function Story() {
return (
<div className="p-20">
<div className="flex flex-col">
<Header1 className="mb-4">Variants</Header1>
<Header2 className="my-4">size=small width=content</Header2>
<SelectGroup>
<Select name="colorScheme" defaultValue="dark">
<SelectTrigger>
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectLabel>Color Scheme</SelectLabel>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectLabel>Other themes</SelectLabel>
<SelectItem value="sunset">Sunset</SelectItem>
<SelectItem value="midnight">Midnight</SelectItem>
<SelectSeparator />
<SelectItem value="lunar">Lunar</SelectItem>
</SelectContent>
<div className="flex h-full max-w-full flex-wrap items-start justify-start gap-2 px-4 py-16">
<Form className="space-y-4">
<div className="flex gap-16">
<ProjectSelector />
<Statuses />
<Select name="static" text="Static" defaultValue={[]} shortcut={{ key: "e" }}>
<SelectItem value={"value"} shortcut={{ key: "1" }}>
Item 1
</SelectItem>
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
Item 2
</SelectItem>
</Select>
</SelectGroup>
<Header2 className="my-4">size=small width=full</Header2>
<SelectGroup>
<Select name="colorScheme" defaultValue="dark">
<SelectTrigger width="full">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="sunset">Sunset</SelectItem>
<SelectItem value="midnight">Midnight</SelectItem>
<SelectSeparator />
<SelectItem value="lunar">Lunar</SelectItem>
</SelectContent>
<Select
variant="tertiary/medium"
text="Tertiary medium"
defaultValue={[]}
shortcut={{ key: "e" }}
>
<SelectItem value={"value"} shortcut={{ key: "1" }}>
Item 1
</SelectItem>
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
Item 2
</SelectItem>
</Select>
</SelectGroup>
<Header2 className="my-4">size=medium width=content</Header2>
<SelectGroup>
<Select name="colorScheme" defaultValue="dark">
<SelectTrigger size="medium">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="sunset">Sunset</SelectItem>
<SelectItem value="midnight">Midnight</SelectItem>
<SelectSeparator />
<SelectItem value="lunar">Lunar</SelectItem>
</SelectContent>
<Select variant="minimal/small" text="Minimal" defaultValue={[]} shortcut={{ key: "e" }}>
<SelectItem value={"value"} shortcut={{ key: "1" }}>
Item 1
</SelectItem>
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
Item 2
</SelectItem>
</Select>
</SelectGroup>
<Header2 className="my-4">size=medium width=full</Header2>
<SelectGroup>
<Select name="colorScheme" defaultValue="dark">
<SelectTrigger size="medium" width="full">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="sunset">Sunset</SelectItem>
<SelectItem value="midnight">Midnight</SelectItem>
<SelectSeparator />
<SelectItem value="lunar">Lunar</SelectItem>
</SelectContent>
<Select
variant="minimal/medium"
text="Tertiary medium"
defaultValue={[]}
shortcut={{ key: "e" }}
>
<SelectItem value={"value"} shortcut={{ key: "1" }}>
Item 1
</SelectItem>
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
Item 2
</SelectItem>
</Select>
</SelectGroup>
</div>
<Select
name="static"
text="Heading"
defaultValue={[]}
showHeading={true}
heading="A heading"
shortcut={{ key: "h" }}
>
<SelectItem value={"value"} shortcut={{ key: "1" }}>
Item 1
</SelectItem>
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
Item 2
</SelectItem>
</Select>
<Select
name="branch2"
heading={"Filter by status..."}
defaultValue={"main"}
items={branches}
>
{(matches) => matches?.map((value) => <SelectItem key={value} value={value} />)}
</Select>
<Button variant="tertiary/small">Submit</Button>
</div>
</Form>
</div>
);
}
const statuses = allTaskRunStatuses.map((status) => ({
title: runStatusTitle(status),
value: status,
}));
function Statuses() {
const navigate = useNavigate();
const location = useOptimisticLocation();
const search = new URLSearchParams(location.search);
const handleChange = useCallback((values: string[]) => {
search.delete("status");
for (const value of values) {
search.append("status", value);
}
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
}, []);
return (
<Select
name="status"
text="Status"
value={search.getAll("status")}
setValue={handleChange}
heading={"Filter by status..."}
items={statuses}
filter={(item, search) => item.title.toLowerCase().includes(search.toLowerCase())}
shortcut={{ key: "s" }}
>
{(matches, { shortcutsEnabled }) => (
<>
{matches?.map((item, index) => (
<SelectItem
key={item.value}
value={item.value}
shortcut={shortcutFromIndex(index, { shortcutsEnabled })}
>
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
</SelectItem>
))}
</>
)}
</Select>
);
}
export const projects = [
{
type: "section" as const,
title: "Apple",
items: [
{
title: "iTunes",
value: "itunes",
},
{
title: "App Store",
value: "appstore",
},
],
},
{
type: "section" as const,
title: "Google",
items: [
{
title: "Maps",
value: "maps",
},
{
title: "Gmail",
value: "gmail",
},
{
title: "Waymo",
value: "waymo",
},
{
title: "Android",
value: "android",
},
],
},
{
type: "section" as const,
title: "Uber",
items: [
{
title: "Planner",
value: "planner",
},
],
},
];
function ProjectSelector() {
const location = useOptimisticLocation();
const search = new URLSearchParams(location.search);
const selected = projects
.find((p) => p.items.some((i) => i.value === search.get("project")))
?.items.find((i) => i.value === search.get("project"));
const searchParams = new URLSearchParams(location.search);
searchParams.delete("project");
return (
<Select
name="project"
defaultValue={selected?.value}
text={selected?.title}
heading="Find project..."
icon={<LogoIcon className="h-3 w-3" />}
items={projects}
shortcut={{ key: "p", modifiers: ["alt"] }}
filter={(item, search, sectionTitle) =>
sectionTitle?.toLowerCase().includes(search.toLowerCase()) ||
item.title.toLowerCase().includes(search.toLowerCase())
}
>
{(matches, { shortcutsEnabled, section }) => (
<SelectGroup>
{section && <SelectGroupLabel>{section.title}</SelectGroupLabel>}
{matches?.map((match, index) => (
<SelectLinkItem
icon={<CircleStackIcon className="size-3" />}
key={match.value}
value={match.value}
to={`?${searchParams.toString()}&project=${match.value}`}
shortcut={shortcutFromIndex(index, { shortcutsEnabled, section })}
>
{match.title}
</SelectLinkItem>
))}
</SelectGroup>
)}
</Select>
);
}
@@ -134,6 +134,10 @@ const stories: Story[] = [
name: "Select",
slug: "select",
},
{
name: "Filter",
slug: "filter",
},
{
name: "Popover",
slug: "popover",
@@ -0,0 +1,95 @@
import { runMigrations } from "graphile-worker";
import { PrismaClient, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { PgNotifyService } from "./pgNotify.server";
import { z } from "zod";
export class GraphileMigrationHelperService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call() {
this.#logDebug("GraphileMigrationHelperService.call");
await this.#detectAndPrepareForMigrations();
await runMigrations({
connectionString: env.DATABASE_URL,
schema: env.WORKER_SCHEMA,
});
}
#logDebug(message: string, args?: any) {
logger.debug(`[migrationHelper] ${message}`, args);
}
async #getLatestMigration() {
const migrationQueryResult = await this.#prismaClient.$queryRawUnsafe(`
SELECT id FROM ${env.WORKER_SCHEMA}.migrations
ORDER BY id DESC LIMIT 1
`);
const MigrationQueryResultSchema = z.array(z.object({ id: z.number() }));
const migrationResults = MigrationQueryResultSchema.parse(migrationQueryResult);
if (!migrationResults.length) {
// no migrations applied yet
return -1;
}
return migrationResults[0].id;
}
async #graphileSchemaExists() {
const schemaCount = await this.#prismaClient.$executeRaw`
SELECT schema_name FROM information_schema.schemata
WHERE schema_name = ${env.WORKER_SCHEMA}
`;
return schemaCount === 1;
}
/** Helper for graphile-worker v0.14.0 migration. No-op if already migrated. */
async #detectAndPrepareForMigrations() {
if (!(await this.#graphileSchemaExists())) {
// no schema yet, likely first start
return;
}
const latestMigration = await this.#getLatestMigration();
if (latestMigration < 0) {
// no migrations found
return;
}
// the first v0.14.0 migration has ID 11
if (latestMigration > 10) {
// already migrated
return;
}
// add 15s to graceful shutdown timeout, just to be safe
const migrationDelayInMs = env.GRACEFUL_SHUTDOWN_TIMEOUT + 15000;
this.#logDebug("Delaying worker startup due to pending migration", {
latestMigration,
migrationDelayInMs,
});
console.log(`⚠️ detected pending graphile migration`);
console.log(`⚠️ notifying running workers`);
const pgNotify = new PgNotifyService();
await pgNotify.call("trigger:graphile:migrate", { latestMigration });
console.log(`⚠️ delaying worker startup by ${migrationDelayInMs}ms`);
await new Promise((resolve) => setTimeout(resolve, migrationDelayInMs));
}
}
@@ -60,6 +60,11 @@ export class IngestSendEvent {
try {
const deliverAt = this.#calculateDeliverAt(options);
if (!environment.organization.runsEnabled) {
logger.debug("IngestSendEvent: Runs are disabled for this organization", environment);
return;
}
return await $transaction(this.#prismaClient, async (tx) => {
const externalAccount = options?.accountId
? await tx.externalAccount.upsert({
@@ -8,6 +8,7 @@ import { $transaction, prisma } from "~/db.server";
import { workerQueue } from "../worker.server";
import { ResumeRunService } from "./resumeRun.server";
import { createHash } from "node:crypto";
import { logger } from "../logger.server";
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
@@ -36,6 +37,13 @@ export class StartRunService {
}
#runIsStartable(run: FoundRun) {
if (!run.organization.runsEnabled) {
logger.debug("StartRunService: Runs are disabled for this organization", {
organizationId: run.organization.id,
});
return false;
}
const startableStatuses = ["PENDING", "WAITING_ON_CONNECTIONS"] as const;
return startableStatuses.includes(run.status);
}
@@ -64,8 +72,6 @@ export class StartRunService {
await $transaction(
this.#prismaClient,
async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const counter = await tx.jobCounter.upsert({
where: { jobId: run.jobId },
update: { lastNumber: { increment: 1 } },
@@ -146,6 +152,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
include: {
queue: true,
environment: true,
organization: true,
version: {
include: {
integrations: {
@@ -55,12 +55,14 @@ export class SecretStore {
}
}
const EncryptedSecretValueSchema = z.object({
export const EncryptedSecretValueSchema = z.object({
nonce: z.string(),
ciphertext: z.string(),
tag: z.string(),
});
export type EncryptedSecretValue = z.infer<typeof EncryptedSecretValueSchema>;
/** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */
class PrismaSecretStore implements SecretStoreProvider {
#prismaClient: PrismaClientOrTransaction;
@@ -181,18 +183,11 @@ class PrismaSecretStore implements SecretStoreProvider {
}
async #decrypt(nonce: string, ciphertext: string, tag: string): Promise<string> {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
this.encryptionKey,
Buffer.from(nonce, "hex")
);
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
return await decryptSecret(this.encryptionKey, {
nonce,
ciphertext,
tag,
});
}
async #encrypt(value: string): Promise<{
@@ -200,19 +195,7 @@ class PrismaSecretStore implements SecretStoreProvider {
ciphertext: string;
tag: string;
}> {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", this.encryptionKey, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
return await encryptSecret(this.encryptionKey, value);
}
}
@@ -234,3 +217,40 @@ export function getSecretStore<
}
}
}
export async function decryptSecret(
encryptionKey: string,
secret: EncryptedSecretValue
): Promise<string> {
const decipher = nodeCrypto.createDecipheriv(
"aes-256-gcm",
encryptionKey,
Buffer.from(secret.nonce, "hex")
);
decipher.setAuthTag(Buffer.from(secret.tag, "hex"));
let decrypted = decipher.update(secret.ciphertext, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
export async function encryptSecret(
encryptionKey: string,
value: string
): Promise<EncryptedSecretValue> {
const nonce = nodeCrypto.randomBytes(12);
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
return {
nonce: nonce.toString("hex"),
ciphertext: encrypted,
tag,
};
}
@@ -4,6 +4,7 @@ import { workerQueue } from "../worker.server";
import { requestUrl } from "~/utils/requestUrl.server";
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
import { logger } from "../logger.server";
export class HandleHttpSourceService {
#prismaClient: PrismaClient;
@@ -19,6 +20,7 @@ export class HandleHttpSourceService {
endpoint: true,
environment: true,
secretReference: true,
organization: true,
},
});
@@ -30,6 +32,13 @@ export class HandleHttpSourceService {
return { status: 200 };
}
if (!triggerSource.organization.runsEnabled) {
logger.debug("HandleHttpSourceService: Runs are disabled for this organization", {
organizationId: triggerSource.organization.id,
});
return { status: 404 };
}
if (!triggerSource.interactive) {
const sourceRequest = await createHttpSourceRequest(request);
@@ -37,8 +37,6 @@ export class HandleWebhookRequestService {
const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
await this.#prismaClient.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const counter = await tx.webhookDeliveryCounter.upsert({
where: { webhookId: webhookEnvironment.id },
update: { lastNumber: { increment: 1 } },
+45 -6
View File
@@ -37,6 +37,10 @@ import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server
import { eventRepository } from "~/v3/eventRepository.server";
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server";
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
import { GraphileMigrationHelperService } from "./db/graphileMigrationHelper.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -136,6 +140,15 @@ const workerCatalog = {
"v3.triggerScheduledTask": z.object({
instanceId: z.string(),
}),
"v3.performTaskAttemptAlerts": z.object({
attemptId: z.string(),
}),
"v3.deliverAlert": z.object({
alertId: z.string(),
}),
"v3.performDeploymentAlerts": z.object({
deploymentId: z.string(),
}),
};
const executionWorkerCatalog = {
@@ -199,9 +212,8 @@ if (env.NODE_ENV === "production") {
}
export async function init() {
// const pgNotify = new PgNotifyService();
// await pgNotify.call("trigger:graphile:migrate", { latestMigration: 10 });
// await new Promise((resolve) => setTimeout(resolve, 10000))
const migrationHelper = new GraphileMigrationHelperService();
await migrationHelper.call();
if (env.WORKER_ENABLED === "true") {
await workerQueue.initialize();
@@ -238,7 +250,7 @@ function getWorkerQueue() {
recurringTasks: {
// Run this every 5 minutes
autoIndexProductionEndpoints: {
pattern: "*/5 * * * *",
match: "*/5 * * * *",
handler: async (payload, job) => {
const service = new RecurringEndpointIndexService();
@@ -247,7 +259,7 @@ function getWorkerQueue() {
},
// Run this every hour
purgeOldIndexings: {
pattern: "0 * * * *",
match: "0 * * * *",
handler: async (payload, job) => {
// Delete indexings that are older than 7 days
await prisma.endpointIndex.deleteMany({
@@ -261,7 +273,7 @@ function getWorkerQueue() {
},
// Run this every hour at the 13 minute mark
purgeOldTaskEvents: {
pattern: "47 * * * *",
match: "47 * * * *",
handler: async (payload, job) => {
await eventRepository.truncateEvents();
},
@@ -533,6 +545,33 @@ function getWorkerQueue() {
return await service.call(payload.instanceId);
},
},
"v3.performTaskAttemptAlerts": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new PerformTaskAttemptAlertsService();
return await service.call(payload.attemptId);
},
},
"v3.deliverAlert": {
priority: 0,
maxAttempts: 8,
handler: async (payload, job) => {
const service = new DeliverAlertService();
return await service.call(payload.alertId);
},
},
"v3.performDeploymentAlerts": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new PerformDeploymentAlertsService();
return await service.call(payload.deploymentId);
},
},
},
});
}
@@ -1,5 +1,4 @@
import { RuntimeEnvironmentType } from "@trigger.dev/database";
import { logger } from "./logger.server";
const environmentSortOrder: RuntimeEnvironmentType[] = [
"DEVELOPMENT",
+15
View File
@@ -325,6 +325,21 @@ export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project:
return `${v3EnvironmentVariablesPath(organization, project)}/new`;
}
export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/alerts`;
}
export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectAlertsPath(organization, project)}/new`;
}
export function v3NewProjectAlertPathConnectToSlackPath(
organization: OrgForPath,
project: ProjectForPath
) {
return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`;
}
export function v3TestPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -0,0 +1,102 @@
import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database";
import { nanoid } from "nanoid";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import { encryptSecret } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService, ServiceValidationError } from "../baseService.server";
export type CreateAlertChannelOptions = {
name: string;
alertTypes: ProjectAlertType[];
deduplicationKey?: string;
channel:
| {
type: "EMAIL";
email: string;
}
| {
type: "WEBHOOK";
url: string;
secret?: string;
}
| {
type: "SLACK";
channelId: string;
channelName: string;
integrationId: string | undefined;
};
};
export class CreateAlertChannelService extends BaseService {
public async call(
projectRef: string,
userId: string,
options: CreateAlertChannelOptions
): Promise<ProjectAlertChannel> {
const project = await findProjectByRef(projectRef, userId);
if (!project) {
throw new ServiceValidationError("Project not found");
}
const existingAlertChannel = options.deduplicationKey
? await this._prisma.projectAlertChannel.findUnique({
where: {
projectId_deduplicationKey: {
projectId: project.id,
deduplicationKey: options.deduplicationKey,
},
},
})
: undefined;
if (existingAlertChannel) {
return await this._prisma.projectAlertChannel.update({
where: { id: existingAlertChannel.id },
data: {
name: options.name,
alertTypes: options.alertTypes,
type: options.channel.type,
properties: await this.#createProperties(options.channel),
},
});
}
const alertChannel = await this._prisma.projectAlertChannel.create({
data: {
friendlyId: generateFriendlyId("alert_channel"),
name: options.name,
alertTypes: options.alertTypes,
projectId: project.id,
type: options.channel.type,
properties: await this.#createProperties(options.channel),
enabled: true,
deduplicationKey: options.deduplicationKey,
userProvidedDeduplicationKey: options.deduplicationKey ? true : false,
},
});
return alertChannel;
}
async #createProperties(channel: CreateAlertChannelOptions["channel"]) {
switch (channel.type) {
case "EMAIL":
return {
email: channel.email,
};
case "WEBHOOK":
return {
url: channel.url,
secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()),
};
case "SLACK":
return {
channelId: channel.channelId,
channelName: channel.channelName,
integrationId: channel.integrationId,
};
}
}
}
@@ -0,0 +1,775 @@
import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { subtle } from "crypto";
import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import {
ProjectAlertEmailProperties,
ProjectAlertSlackProperties,
ProjectAlertSlackStorage,
ProjectAlertWebhookProperties,
} from "~/models/projectAlert.server";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { sendEmail } from "~/services/email.server";
import { logger } from "~/services/logger.server";
import { decryptSecret } from "~/services/secrets/secretStore.server";
import { workerQueue } from "~/services/worker.server";
import { BaseService } from "../baseService.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
type FoundAlert = Prisma.Result<
typeof prisma.projectAlert,
{
include: {
channel: true;
project: {
include: {
organization: true;
};
};
environment: true;
taskRunAttempt: {
include: {
taskRun: true;
backgroundWorkerTask: true;
backgroundWorker: true;
};
};
workerDeployment: {
include: {
worker: {
include: {
tasks: true;
};
};
};
};
};
},
"findUniqueOrThrow"
>;
export class DeliverAlertService extends BaseService {
public async call(alertId: string) {
const alert = await this._prisma.projectAlert.findUnique({
where: { id: alertId },
include: {
channel: true,
project: {
include: {
organization: true,
},
},
environment: true,
taskRunAttempt: {
include: {
taskRun: true,
backgroundWorkerTask: true,
backgroundWorker: true,
},
},
workerDeployment: {
include: {
worker: {
include: {
tasks: true,
},
},
},
},
},
});
if (!alert) {
return;
}
if (alert.status !== "PENDING") {
return;
}
if (alert.environment.type === "DEVELOPMENT") {
return;
}
switch (alert.channel.type) {
case "EMAIL": {
await this.#sendEmail(alert);
break;
}
case "SLACK": {
await this.#sendSlack(alert);
break;
}
case "WEBHOOK": {
await this.#sendWebhook(alert);
break;
}
default: {
assertNever(alert.channel.type);
}
}
await this._prisma.projectAlert.update({
where: { id: alertId },
data: {
status: "SENT",
},
});
}
async #sendEmail(alert: FoundAlert) {
const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties);
if (!emailProperties.success) {
logger.error("[DeliverAlert] Failed to parse email properties", {
issues: emailProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
await sendEmail({
email: "alert-attempt",
to: emailProperties.data.email,
taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier,
fileName: alert.taskRunAttempt.backgroundWorkerTask.filePath,
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
version: alert.taskRunAttempt.backgroundWorker.version,
environment: alert.environment.slug,
error: createJsonErrorObject(taskRunError.data),
attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
});
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
await sendEmail({
email: "alert-deployment-failure",
to: emailProperties.data.email,
version: alert.workerDeployment.version,
environment: alert.environment.slug,
shortCode: alert.workerDeployment.shortCode,
failedAt: alert.workerDeployment.failedAt ?? new Date(),
error: preparedError,
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
});
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
await sendEmail({
email: "alert-deployment-success",
to: emailProperties.data.email,
version: alert.workerDeployment.version,
environment: alert.environment.slug,
shortCode: alert.workerDeployment.shortCode,
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
});
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
default: {
assertNever(alert.type);
}
}
}
async #sendWebhook(alert: FoundAlert) {
const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties);
if (!webhookProperties.success) {
logger.error("[DeliverAlert] Failed to parse webhook properties", {
issues: webhookProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
const error = createJsonErrorObject(taskRunError.data);
const payload = {
task: {
id: alert.taskRunAttempt.taskRun.taskIdentifier,
filePath: alert.taskRunAttempt.backgroundWorkerTask.filePath,
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
},
attempt: {
id: alert.taskRunAttempt.friendlyId,
number: alert.taskRunAttempt.number,
startedAt: alert.taskRunAttempt.startedAt,
status: alert.taskRunAttempt.status,
},
run: {
id: alert.taskRunAttempt.taskRun.friendlyId,
isTest: alert.taskRunAttempt.taskRun.isTest,
createdAt: alert.taskRunAttempt.taskRun.createdAt,
idempotencyKey: alert.taskRunAttempt.taskRun.idempotencyKey,
},
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
error,
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
const payload = {
deployment: {
id: alert.workerDeployment.friendlyId,
status: alert.workerDeployment.status,
version: alert.workerDeployment.version,
shortCode: alert.workerDeployment.shortCode,
failedAt: alert.workerDeployment.failedAt ?? new Date(),
},
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
error: preparedError,
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
const payload = {
deployment: {
id: alert.workerDeployment.friendlyId,
status: alert.workerDeployment.status,
version: alert.workerDeployment.version,
shortCode: alert.workerDeployment.shortCode,
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
},
tasks:
alert.workerDeployment.worker?.tasks.map((task) => ({
id: task.slug,
filePath: task.filePath,
exportName: task.exportName,
triggerSource: task.triggerSource,
})) ?? [],
environment: {
id: alert.environment.id,
type: alert.environment.type,
slug: alert.environment.slug,
},
organization: {
id: alert.project.organizationId,
slug: alert.project.organization.slug,
name: alert.project.organization.title,
},
project: {
id: alert.project.id,
ref: alert.project.externalRef,
slug: alert.project.slug,
name: alert.project.name,
},
};
await this.#deliverWebhook(payload, webhookProperties.data);
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
default: {
assertNever(alert.type);
}
}
}
async #sendSlack(alert: FoundAlert) {
const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties);
if (!slackProperties.success) {
logger.error("[DeliverAlert] Failed to parse slack properties", {
issues: slackProperties.error.issues,
properties: alert.channel.properties,
});
return;
}
// Get the org integration
const integration = slackProperties.data.integrationId
? await this._prisma.organizationIntegration.findUnique({
where: {
id: slackProperties.data.integrationId,
organizationId: alert.project.organizationId,
},
include: {
tokenReference: true,
},
})
: await this._prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: alert.project.organizationId,
},
orderBy: {
createdAt: "desc",
},
include: {
tokenReference: true,
},
});
if (!integration) {
logger.error("[DeliverAlert] Slack integration not found", {
alert,
});
return;
}
// Get the client
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
integration,
{ forceBotToken: true }
);
switch (alert.type) {
case "TASK_RUN_ATTEMPT": {
if (alert.taskRunAttempt) {
// Find existing storage by the run ID
const storage = await this._prisma.projectAlertStorage.findFirst({
where: {
alertChannelId: alert.channel.id,
alertType: alert.type,
storageId: alert.taskRunAttempt.taskRunId,
},
});
const storageData = storage
? ProjectAlertSlackStorage.safeParse(storage.storageData)
: undefined;
const thread_ts =
storageData && storageData.success ? storageData.data.message_ts : undefined;
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
if (!taskRunError.success) {
logger.error("[DeliverAlert] Failed to parse task run error", {
issues: taskRunError.error.issues,
taskAttemptError: alert.taskRunAttempt.error,
});
return;
}
const error = createJsonErrorObject(taskRunError.data);
const exportName = alert.taskRunAttempt.backgroundWorkerTask.exportName;
const version = alert.taskRunAttempt.backgroundWorker.version;
const environment = alert.environment.slug;
const taskIdentifier = alert.taskRunAttempt.backgroundWorkerTask.slug;
const timestamp = alert.taskRunAttempt.completedAt ?? new Date();
const runId = alert.taskRunAttempt.taskRun.friendlyId;
const attemptNumber = alert.taskRunAttempt.number;
try {
const message = await client.chat.postMessage({
thread_ts,
channel: slackProperties.data.channelId,
text: `Task error in ${alert.taskRunAttempt.backgroundWorkerTask.exportName} [${alert.taskRunAttempt.backgroundWorker.version}.${alert.environment.slug}]`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rotating_light: Error in *${exportName}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`${error.stackTrace ?? error.message}\`\`\``,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "divider",
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Investigate",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
},
],
},
],
});
// Upsert the storage
if (message.ts) {
if (storage) {
await this._prisma.projectAlertStorage.update({
where: {
id: storage.id,
},
data: {
storageData: {
message_ts: message.ts,
},
},
});
} else {
await this._prisma.projectAlertStorage.create({
data: {
alertChannelId: alert.channel.id,
alertType: alert.type,
storageId: alert.taskRunAttempt.taskRunId,
storageData: {
message_ts: message.ts,
},
projectId: alert.project.id,
},
});
}
}
} catch (error) {
logger.error("[DeliverAlert] Failed to send slack message", {
error,
alert,
});
throw error;
}
} else {
logger.error("[DeliverAlert] Task run attempt not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_FAILURE": {
if (alert.workerDeployment) {
const preparedError = DeploymentPresenter.prepareErrorData(
alert.workerDeployment.errorData
);
if (!preparedError) {
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
errorData: alert.workerDeployment.errorData,
});
return;
}
const version = alert.workerDeployment.version;
const environment = alert.environment.slug;
const timestamp = alert.workerDeployment.failedAt ?? new Date();
try {
await client.chat.postMessage({
channel: slackProperties.data.channelId,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rotating_light: Deployment failed *${version}.${environment}* _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Deployment",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
},
],
},
],
});
} catch (error) {
logger.error("[DeliverAlert] Failed to send slack message", {
error,
alert,
});
throw error;
}
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
}
break;
}
case "DEPLOYMENT_SUCCESS": {
if (alert.workerDeployment) {
const version = alert.workerDeployment.version;
const environment = alert.environment.slug;
const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0;
const timestamp = alert.workerDeployment.deployedAt ?? new Date();
await client.chat.postMessage({
channel: slackProperties.data.channelId,
text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:rocket: Deployed *${version}.${environment}* successfully _<!date^${Math.round(
timestamp.getTime() / 1000
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
},
],
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Deployment",
},
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
},
],
},
],
});
return;
} else {
logger.error("[DeliverAlert] Worker deployment not found", {
alert,
});
return;
}
}
}
}
async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) {
const rawPayload = JSON.stringify(payload);
const hashPayload = Buffer.from(rawPayload, "utf-8");
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhook.secret);
const hmacSecret = Buffer.from(secret, "utf-8");
const key = await subtle.importKey(
"raw",
hmacSecret,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await subtle.sign("HMAC", key, hashPayload);
const signatureHex = Buffer.from(signature).toString("hex");
// Send the webhook to the URL specified in webhook.url
const response = await fetch(webhook.url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-trigger-signature-hmacsha256": signatureHex,
},
body: rawPayload,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
logger.error("[DeliverAlert] Failed to send alert webhook", {
status: response.status,
statusText: response.statusText,
url: webhook.url,
body: payload,
signature,
});
throw new Error(`Failed to send alert webhook to ${webhook.url}`);
}
}
static async enqueue(
alertId: string,
tx: PrismaClientOrTransaction,
options?: { runAt?: Date; queueName?: string }
) {
return await workerQueue.enqueue(
"v3.deliverAlert",
{
alertId,
},
{
tx,
runAt: options?.runAt,
jobKey: `deliverAlert:${alertId}`,
queueName: options?.queueName,
}
);
}
}
@@ -0,0 +1,74 @@
import { ProjectAlertChannel, ProjectAlertType, WorkerDeployment } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService } from "../baseService.server";
import { DeliverAlertService } from "./deliverAlert.server";
export class PerformDeploymentAlertsService extends BaseService {
public async call(deploymentId: string) {
const deployment = await this._prisma.workerDeployment.findUnique({
where: { id: deploymentId },
});
if (!deployment) {
return;
}
const alertType =
deployment.status === "DEPLOYED" ? "DEPLOYMENT_SUCCESS" : "DEPLOYMENT_FAILURE";
// Find all the alert channels
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId: deployment.projectId,
alertTypes: {
has: alertType,
},
enabled: true,
},
});
for (const alertChannel of alertChannels) {
await this.#createAndSendAlert(alertChannel, deployment, alertType);
}
}
async #createAndSendAlert(
alertChannel: ProjectAlertChannel,
deployment: WorkerDeployment,
alertType: ProjectAlertType
) {
await $transaction(this._prisma, async (tx) => {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
channelId: alertChannel.id,
projectId: deployment.projectId,
environmentId: deployment.environmentId,
status: "PENDING",
type: alertType,
workerDeploymentId: deployment.id,
},
});
await DeliverAlertService.enqueue(alert.id, tx, {
queueName: `alert-channel:${alertChannel.id}`,
});
});
}
static async enqueue(deploymentId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.performDeploymentAlerts",
{
deploymentId,
},
{
tx,
runAt,
jobKey: `performDeploymentAlerts:${deploymentId}`,
}
);
}
}
@@ -0,0 +1,82 @@
import { Prisma, ProjectAlertChannel } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { BaseService } from "../baseService.server";
import { DeliverAlertService } from "./deliverAlert.server";
type FoundTaskAttempt = Prisma.Result<
typeof prisma.taskRunAttempt,
{ include: { taskRun: true; backgroundWorkerTask: true; runtimeEnvironment: true } },
"findUniqueOrThrow"
>;
export class PerformTaskAttemptAlertsService extends BaseService {
public async call(attemptId: string) {
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
where: { id: attemptId },
include: {
taskRun: true,
backgroundWorkerTask: true,
runtimeEnvironment: true,
},
});
if (!taskAttempt) {
return;
}
if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
// Find all the alert channels
const alertChannels = await this._prisma.projectAlertChannel.findMany({
where: {
projectId: taskAttempt.taskRun.projectId,
alertTypes: {
has: "TASK_RUN_ATTEMPT",
},
enabled: true,
},
});
for (const alertChannel of alertChannels) {
await this.#createAndSendAlert(alertChannel, taskAttempt);
}
}
async #createAndSendAlert(alertChannel: ProjectAlertChannel, taskAttempt: FoundTaskAttempt) {
await $transaction(this._prisma, async (tx) => {
const alert = await this._prisma.projectAlert.create({
data: {
friendlyId: generateFriendlyId("alert"),
channelId: alertChannel.id,
projectId: taskAttempt.taskRun.projectId,
environmentId: taskAttempt.runtimeEnvironmentId,
status: "PENDING",
type: "TASK_RUN_ATTEMPT",
taskRunAttemptId: taskAttempt.id,
},
});
await DeliverAlertService.enqueue(alert.id, tx, {
queueName: `alert-channel:${alertChannel.id}`,
});
});
}
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.performTaskAttemptAlerts",
{
attemptId,
},
{
tx,
runAt,
jobKey: `performTaskAttemptAlerts:${attemptId}`,
}
);
}
}
@@ -19,6 +19,7 @@ import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.se
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
import { CreateCheckpointService } from "./createCheckpoint.server";
import { TaskRun } from "@trigger.dev/database";
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
@@ -154,9 +155,13 @@ export class CompleteAttemptService extends BaseService {
},
});
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
if (environment.type !== "DEVELOPMENT") {
await PerformTaskAttemptAlertsService.enqueue(taskRunAttempt.id, this._prisma);
}
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const retryAt = new Date(completion.retry.timestamp);
// Retry the task run
@@ -172,7 +172,7 @@ export async function createBackgroundTasks(
},
});
if (taskQueue.concurrencyLimit) {
if (typeof taskQueue.concurrencyLimit === "number") {
await marqs?.updateQueueConcurrencyLimits(
environment,
taskQueue.name,
@@ -9,6 +9,7 @@ import { projectPubSub } from "./projectPubSub.server";
import { marqs } from "~/v3/marqs/index.server";
import { logger } from "~/services/logger.server";
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
export class CreateDeployedBackgroundWorkerService extends BaseService {
public async call(
@@ -98,6 +99,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
}
await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma);
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
return backgroundWorker;
});
@@ -0,0 +1,35 @@
import { OrganizationIntegration } from "@trigger.dev/database";
import { BaseService } from "./baseService.server";
import { WebClient } from "@slack/web-api";
import { env } from "~/env.server";
import { $transaction } from "~/db.server";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
export class CreateOrgIntegrationService extends BaseService {
public async call(
userId: string,
orgId: string,
serviceName: string,
code: string
): Promise<OrganizationIntegration | undefined> {
// Get the org
const org = await this._prisma.organization.findUnique({
where: {
id: orgId,
members: {
some: {
userId,
},
},
},
});
if (!org) {
throw new Error("Organization not found");
}
return OrgIntegrationRepository.createOrgIntegration(serviceName, code, org);
}
}
@@ -1,3 +1,4 @@
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { BaseService } from "./baseService.server";
export class DeploymentIndexFailed extends BaseService {
@@ -22,6 +23,8 @@ export class DeploymentIndexFailed extends BaseService {
},
});
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
return deployment;
}
}
@@ -15,9 +15,10 @@ export class StartDeploymentIndexing extends BaseService {
friendlyId: deploymentId,
},
data: {
imageReference: registryProxy
? registryProxy.rewriteImageReference(body.imageReference)
: body.imageReference,
imageReference:
registryProxy && body.selfHosted !== true
? registryProxy.rewriteImageReference(body.imageReference)
: body.imageReference,
status: "DEPLOYING",
},
});
@@ -1,6 +1,7 @@
import { logger } from "~/services/logger.server";
import { BaseService } from "./baseService.server";
import { workerQueue } from "~/services/worker.server";
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
export class TimeoutDeploymentService extends BaseService {
public async call(id: string, fromStatus: string, errorMessage: string) {
@@ -32,6 +33,8 @@ export class TimeoutDeploymentService extends BaseService {
errorData: { message: errorMessage, name: "TimeoutError" },
},
});
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
}
static async enqueue(
@@ -88,8 +88,6 @@ export class TriggerTaskService extends BaseService {
const lockId = taskIdentifierToLockId(taskId);
const run = await $transaction(this._prisma, async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
const lockedToBackgroundWorker = body.options?.lockToVersion
? await tx.backgroundWorker.findUnique({
where: {
+6 -1
View File
@@ -30,6 +30,8 @@
"/public/build"
],
"dependencies": {
"@ariakit/react": "^0.4.6",
"@ariakit/react-core": "^0.4.6",
"@aws-sdk/client-sqs": "^3.445.0",
"@codemirror/autocomplete": "^6.3.1",
"@codemirror/commands": "^6.1.2",
@@ -82,6 +84,7 @@
"@remix-run/serve": "2.1.0",
"@remix-run/server-runtime": "2.1.0",
"@remix-run/v1-meta": "^0.1.3",
"@slack/web-api": "^6.8.1",
"@socket.io/redis-adapter": "^8.3.0",
"@tabler/icons-react": "^2.39.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -113,7 +116,7 @@
"evt": "^2.4.13",
"express": "^4.18.1",
"framer-motion": "^10.12.11",
"graphile-worker": "^0.13.0",
"graphile-worker": "0.16.6",
"highlight.run": "^7.3.4",
"humanize-duration": "^3.27.3",
"intl-parse-accept-language": "^1.0.0",
@@ -124,11 +127,13 @@
"lodash.omit": "^4.5.0",
"lucide-react": "^0.229.0",
"marked": "^4.0.18",
"match-sorter": "^6.3.4",
"morgan": "^1.10.0",
"nanoid": "^3.3.4",
"non.geist": "^1.0.2",
"ohash": "^1.1.3",
"openai": "^4.33.1",
"parse-duration": "^1.1.0",
"posthog-js": "^1.93.3",
"posthog-node": "^3.1.3",
"prism-react-renderer": "^1.3.5",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+17 -3
View File
@@ -16,7 +16,21 @@ export const client = new TriggerClient({
});
```
## 2. Set your environment variables on your server
## 2. Include your Jobs in `sideEffects` if needed
If your `package.json` does not declare `sideEffects`, you can safely skip this step. Otherwise, include a glob pattern that matches your jobs so that they are not tree shaken away in your production bundle.
```json
// package.json
{
"name": "my-app",
"sideEffects": [
"./src/jobs/**/*.ts"
]
}
```
## 3. Set your environment variables on your server
Ensure that your `TRIGGER_API_KEY` (or whatever you've set it to) environment variable is set on your server, to the correct value from the "Environments & API Keys" page in your Trigger.dev dashboard.
@@ -31,11 +45,11 @@ The exact instructions will vary depending on where you deploy to. Here are the
- [Cloudflare Workers](https://developers.cloudflare.com/workers/platform/environment-variables/)
- [Netlify](https://docs.netlify.com/environment-variables/overview/)
## 3. Deploy your code as usual
## 4. Deploy your code as usual
Your Job code lives in your codebase, so you can deploy it as you normally would.
## 4. Connect the Endpoint for the first time
## 5. Connect the Endpoint for the first time
1. Go to the "Environments & API Keys" page in your Trigger.dev dashboard
![Go to the Environments & API Keys page ](/images/environments-link.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+54 -110
View File
@@ -1,14 +1,8 @@
{
"$schema": "https://mintlify.com/schema.json",
"name": "Trigger.dev",
"openapi": [
"/openapi.yml",
"/v3-openapi.json"
],
"versions": [
"v3 (Developer Preview)",
"v2"
],
"openapi": ["/openapi.yml", "/v3-openapi.json"],
"versions": ["v3 (Developer Preview)", "v2"],
"logo": {
"dark": "/logo/dark.png",
"light": "/logo/light.png",
@@ -48,16 +42,6 @@
"name": "Home"
},
"tabs": [
{
"name": "v3 Developer Preview",
"url": "https://trigger.dev/docs/v3",
"version": "v2"
},
{
"name": "v2",
"url": "https://trigger.dev/docs/documentation",
"version": "v3 (Developer Preview)"
},
{
"name": "Integrations",
"url": "integrations",
@@ -72,6 +56,16 @@
"name": "Examples",
"url": "https://trigger.dev/apis",
"version": "v2"
},
{
"name": "v3 Developer Preview",
"url": "https://trigger.dev/docs/v3",
"version": "v2"
},
{
"name": "v2",
"url": "https://trigger.dev/docs/documentation",
"version": "v3 (Developer Preview)"
}
],
"redirects": [
@@ -96,9 +90,7 @@
{
"group": "",
"version": "v3 (Developer Preview)",
"pages": [
"v3/introduction"
]
"pages": ["v3/introduction"]
},
{
"group": "Getting Started",
@@ -121,12 +113,7 @@
"v3/apikeys",
{
"group": "Task types",
"pages": [
"v3/tasks-regular",
"v3/tasks-scheduled",
"v3/tasks-zod",
"v3/tasks-webhooks"
]
"pages": ["v3/tasks-regular", "v3/tasks-scheduled", "v3/tasks-zod", "v3/tasks-webhooks"]
},
"v3/trigger-config"
]
@@ -134,10 +121,7 @@
{
"group": "Development",
"version": "v3 (Developer Preview)",
"pages": [
"v3/cli-dev",
"v3/run-tests"
]
"pages": ["v3/cli-dev", "v3/run-tests"]
},
{
"group": "Deployment",
@@ -148,9 +132,7 @@
"v3/github-actions",
{
"group": "Deployment integrations",
"pages": [
"v3/vercel-integration"
]
"pages": ["v3/vercel-integration"]
}
]
},
@@ -184,20 +166,30 @@
"v3/automated-tests"
]
},
{
"group": "Dashboard",
"version": "v3 (Developer Preview)",
"pages": [
"v3/dashboard-overview",
"v3/dashboard-runs",
"v3/dashboard-tests",
"v3/dashboard-environment-variables"
]
},
{
"group": "API reference",
"version": "v3 (Developer Preview)",
"pages": [
{
"group": "Runs API",
"pages": [
"v3/management-retrieve-run",
"v3/management-replay-run",
"v3/management-cancel-run"
]
},
{
"group": "Schedules API",
"pages": [
"v3/management-list-schedules",
"v3/management-create-schedule",
"v3/management-retrieve-schedule",
"v3/management-update-schedule",
"v3/management-delete-schedule",
"v3/management-deactivate-schedule",
"v3/management-activate-schedule"
]
},
{
"group": "Functions",
"pages": [
@@ -226,9 +218,7 @@
},
{
"group": "Objects",
"pages": [
"v3/reference-context"
]
"pages": ["v3/reference-context"]
},
{
"group": "CLI",
@@ -242,26 +232,6 @@
"v3/reference-cli-build",
"v3/reference-cli-who-am-i"
]
},
{
"group": "Runs API",
"pages": [
"v3/management-retrieve-run",
"v3/management-replay-run",
"v3/management-cancel-run"
]
},
{
"group": "Schedules API",
"pages": [
"v3/management-list-schedules",
"v3/management-create-schedule",
"v3/management-retrieve-schedule",
"v3/management-update-schedule",
"v3/management-delete-schedule",
"v3/management-deactivate-schedule",
"v3/management-activate-schedule"
]
}
]
},
@@ -277,11 +247,7 @@
{
"group": "Open source",
"version": "v3 (Developer Preview)",
"pages": [
"v3/github-repo",
"v3/open-source-self-hosting",
"v3/open-source-contributing"
]
"pages": ["v3/github-repo", "v3/open-source-self-hosting", "v3/open-source-contributing"]
},
{
"group": "Help",
@@ -459,6 +425,7 @@
},
{
"group": "Overview",
"version": "v2",
"pages": [
"integrations/introduction",
{
@@ -479,13 +446,11 @@
},
{
"group": "Integrations",
"version": "v2",
"pages": [
{
"group": "Airtable",
"pages": [
"integrations/apis/airtable",
"integrations/apis/airtable-tasks"
]
"pages": ["integrations/apis/airtable", "integrations/apis/airtable-tasks"]
},
{
"group": "GitHub",
@@ -511,25 +476,16 @@
},
{
"group": "Plain",
"pages": [
"integrations/apis/plain",
"integrations/apis/plain-tasks"
]
"pages": ["integrations/apis/plain", "integrations/apis/plain-tasks"]
},
"integrations/apis/replicate",
{
"group": "SendGrid",
"pages": [
"integrations/apis/sendgrid",
"integrations/apis/sendgrid-tasks"
]
"pages": ["integrations/apis/sendgrid", "integrations/apis/sendgrid-tasks"]
},
{
"group": "Resend",
"pages": [
"integrations/apis/resend",
"integrations/apis/resend-tasks"
]
"pages": ["integrations/apis/resend", "integrations/apis/resend-tasks"]
},
{
"group": "Shopify",
@@ -541,10 +497,7 @@
},
{
"group": "Slack",
"pages": [
"integrations/apis/slack",
"integrations/apis/slack-tasks"
]
"pages": ["integrations/apis/slack", "integrations/apis/slack-tasks"]
},
"integrations/apis/stripe",
{
@@ -560,6 +513,7 @@
},
{
"group": "SDK",
"version": "v2",
"pages": [
"sdk/introduction",
{
@@ -569,9 +523,7 @@
"sdk/triggerclient/constructor",
{
"group": "Instance properties",
"pages": [
"sdk/triggerclient/store"
]
"pages": ["sdk/triggerclient/store"]
},
{
"group": "Instance methods",
@@ -634,10 +586,7 @@
"sdk/dynamictrigger/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamictrigger/register",
"sdk/dynamictrigger/unregister"
]
"pages": ["sdk/dynamictrigger/register", "sdk/dynamictrigger/unregister"]
}
]
},
@@ -648,10 +597,7 @@
"sdk/dynamicschedule/constructor",
{
"group": "Instance methods",
"pages": [
"sdk/dynamicschedule/register",
"sdk/dynamicschedule/unregister"
]
"pages": ["sdk/dynamicschedule/register", "sdk/dynamicschedule/unregister"]
}
]
},
@@ -663,12 +609,12 @@
},
{
"group": "HTTP Reference",
"pages": [
"sdk/api-reference/events/create-an-event"
]
"version": "v2",
"pages": ["sdk/api-reference/events/create-an-event"]
},
{
"group": "React SDK",
"version": "v2",
"pages": [
"sdk/react/introduction",
"sdk/react/triggerprovider",
@@ -680,9 +626,7 @@
{
"group": "Overview",
"version": "v2",
"pages": [
"examples/introduction"
]
"pages": ["examples/introduction"]
}
],
"footerSocials": {
@@ -690,4 +634,4 @@
"github": "https://github.com/triggerdotdev",
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
}
}
}
+19 -19
View File
@@ -3,24 +3,24 @@ title: "Feature matrix"
description: "What features are currently available in the Developer Preview"
---
| Feature | Description | Status |
| ----------------------------------------------------------------------------------- | -------------------------------------------------- | ------ |
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | ✅ |
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | |
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
| Logs view with search | All logs view with filtering and full text search | ⏳ |
| Alerts | Add alerts in the UI for errors and queue backlogs | ⏳ |
| Notifications | Send data to your web app from a run | ⏳ |
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
| Feature | Description | Status |
| ----------------------------------------------------------------------------------- | ------------------------------------------------- | ------ |
| [Regular tasks](/v3/tasks-regular) | A task that can be triggered from anywhere | ✅ |
| [Triggering](/v3/triggering) | Triggering and batch triggering tasks | ✅ |
| [Testing from the dashboard](/v3/run-tests) | Test your tasks from the dashboard | ✅ |
| [Queues and concurrency controls](/v3/queue-concurrency) | Queues and concurrency controls | ✅ |
| [Per-tenant queuing](/v3/queue-concurrency#concurrency-keys-and-per-tenant-queuing) | Separate queues for each of your users | ✅ |
| [Reattempts and retrying](/v3/errors-retrying) | Write reliable tasks using retries | ✅ |
| [Atomic versioning](/v3/versioning) | Each deploy creates a new version | ✅ |
| [Deploy via CLI](/v3/cli-deploy) | Deploy from the command line | ✅ |
| [Deploy via GitHub Actions](/v3/github-actions) | Deploy using GitHub Actions | ✅ |
| Alerts | Add alerts in the UI for errors and deploys | ✅ |
| [Scheduled tasks](/v3/tasks-scheduled) | A task that can be triggered on a schedule | |
| [Zod tasks](/v3/tasks-zod) | Define tasks using Zod schemas | ⏳ |
| [Webhook tasks](/v3/tasks-webhooks) | A task that can be triggered by a webhook | ⏳ |
| Full text search of runs | Find a run by searching the payload and output | ⏳ |
| Logs view with search | All logs view with filtering and full text search | ⏳ |
| Notifications | Send data to your web app from a run | ⏳ |
| Rollbacks | Easily rollback changes when errors happen | ⏳ |
[Let us know](https://trigger.dev/discord) what we should prioritize and what we are missing.
+18 -4
View File
@@ -5,7 +5,10 @@ description: "You can easily deploy your tasks with GitHub actions."
This simple GitHub action file will deploy you Trigger.dev tasks when new code is pushed to the `main` branch and the `trigger` directory has changes in it.
<Warning>The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details.</Warning>
<Warning>
The deploy step will fail if any version mismatches are detected. Please see the [version
pinning](/v3/github-actions#version-pinning) section for more details.
</Warning>
<CodeGroup>
@@ -30,7 +33,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: npm install
@@ -41,7 +44,6 @@ jobs:
npx trigger.dev@beta deploy
```
```yaml .github/workflows/release-trigger-staging.yml
name: Deploy to Trigger.dev (staging)
@@ -60,7 +62,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install dependencies
run: npm install
@@ -70,6 +72,7 @@ jobs:
run: |
npx trigger.dev@beta deploy --env staging
```
</CodeGroup>
If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file.
@@ -78,6 +81,17 @@ You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can cr
To set it in GitHub go to your repository, click on "Settings", "Secrets and variables" and then "Actions". Add a new secret with the name `TRIGGER_ACCESS_TOKEN` and use the value of your access token.
<Accordion title="How to add TRIGGER_ACCESS_TOKEN in GitHub">
1. Go to your repository on GitHub.
2. Click on "Settings".
3. Click on "Secrets and variables" -> "Actions"
4. Click on "New repository secret".
5. Add the name `TRIGGER_ACCESS_TOKEN` and the value of your access token.
![Add TRIGGER_ACCESS_TOKEN in GitHub](/images/v3/github-access-token.png)
</Accordion>
## Version pinning
The CLI and `@trigger.dev/*` package versions need to be in sync, otherwise there will be errors and unpredictable behavior. Hence, the `deploy` command will automatically fail during CI on any version mismatches.
+604
View File
@@ -0,0 +1,604 @@
import { z } from "zod";
import * as v from "valibot";
import { wrap } from "@typeschema/valibot";
export type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
export type TriggerResult = {
id: string;
};
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type RunMetadata = {
run: string;
};
export type inferContext<TContextBuilder extends AnyContextBuilder> =
TContextBuilder extends ContextBuilder<infer TContext, infer TContextOverrides>
? TContext extends UnsetMarker
? unknown
: TContextOverrides extends UnsetMarker
? Simplify<TContext>
: Simplify<Overwrite<TContext, TContextOverrides>>
: never;
export type RunFnParams<TPayload, TContext extends AnyContextBuilder> = {
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
meta: RunMetadata;
/** Context added by task middleware */
ctx: inferContext<TContext>;
payload: TPayload;
};
/**
* See https://github.com/microsoft/TypeScript/issues/41966#issuecomment-758187996
* Fixes issues with iterating over keys of objects with index signatures.
* Without this, iterations over keys of objects with index signatures will lose
* type information about the keys and only the index signature will remain.
* @internal
*/
export type WithoutIndexSignature<TObj> = {
[K in keyof TObj as string extends K ? never : number extends K ? never : K]: TObj[K];
};
/**
* @internal
* Overwrite properties in `TType` with properties in `TWith`
* Only overwrites properties when the type to be overwritten
* is an object. Otherwise it will just use the type from `TWith`.
*/
export type Overwrite<TType, TWith> = TWith extends any
? TType extends object
? {
[K in
| keyof WithoutIndexSignature<TType>
| keyof WithoutIndexSignature<TWith>]: K extends keyof TWith // Exclude index signature from keys
? TWith[K]
: K extends keyof TType
? TType[K]
: never;
} & (string extends keyof TWith // Handle cases with an index signature
? { [key: string]: TWith[string] }
: number extends keyof TWith
? { [key: number]: TWith[number] }
: // eslint-disable-next-line @typescript-eslint/ban-types
{})
: TWith
: never;
/** @internal */
export const contextMiddlewareMarker = "contextMiddlewareMarker" as "contextMiddlewareMarker" & {
__brand: "contextMiddlewareMarker";
};
type ContextMiddlewareMarker = typeof contextMiddlewareMarker;
interface ContextMiddlewareResultBase {
/**
* All middlewares should pass through their `next()`'s output.
* Requiring this marker makes sure that can't be forgotten at compile-time.
*/
readonly marker: ContextMiddlewareMarker;
}
interface ContextMiddlewareOKResult<_TContextOverride> extends ContextMiddlewareResultBase {
ok: true;
data: unknown;
}
interface ContextMiddlewareErrorResult<_TContextOverride> extends ContextMiddlewareResultBase {
ok: false;
error: Error; // should be our error
}
/**
* @internal
*/
export type ContextMiddlewareResult<_TContextOverride> =
| ContextMiddlewareErrorResult<_TContextOverride>
| ContextMiddlewareOKResult<_TContextOverride>;
export type ContextMiddlewareFunction<TContext, TContextOverridesIn, $ContextOverridesOut> = {
(opts: {
ctx: Simplify<Overwrite<TContext, TContextOverridesIn>>;
meta: RunMetadata;
next: {
(): Promise<ContextMiddlewareResult<TContextOverridesIn>>;
<$ContextOverride>(ctx: $ContextOverride): Promise<ContextMiddlewareResult<$ContextOverride>>;
};
}): Promise<ContextMiddlewareResult<$ContextOverridesOut>>;
};
export const unsetMarker = Symbol("unsetMarker");
export type UnsetMarker = typeof unsetMarker;
export interface ContextBuilder<TContext extends object, TContextOverrides> {
use<$ContextOverridesOut>(
fn: ContextMiddlewareFunction<TContext, TContextOverrides, $ContextOverridesOut>
): ContextBuilder<TContext, Overwrite<TContextOverrides, $ContextOverridesOut>>;
}
export type AnyContextBuilder = ContextBuilder<any, any>;
export function createContext<TContext extends object>(
initialContext?: TContext
): ContextBuilder<TContext, UnsetMarker> {
const builder: AnyContextBuilder = {
use(middlewareFn) {
return {} as AnyContextBuilder;
},
};
return builder;
}
const contextBuilder = createContext({ foo: "bar" });
const context = contextBuilder
.use((opts) => {
return opts.next({
baz: "whatever",
});
})
.use((opts) => {
return opts.next({
db: {
find: async (id: string) => {
return "hello";
},
},
});
});
type ctx = inferContext<typeof context>;
const contextBuilder2 = createContext();
type ctx2 = inferContext<typeof contextBuilder2>;
const contextBuilder3 = createContext({ bar: "baz" });
type ctx3 = inferContext<typeof contextBuilder3>;
const contextBuilder4 = createContext().use((opts) => {
return opts.next({
hello: "world",
});
});
type ctx4 = inferContext<typeof contextBuilder4>;
export type TaskOptions<
TOutput,
TContext extends AnyContextBuilder,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
schema?: TParser;
context?: TContext;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
};
export interface Task<
TOutput,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
* @param options
* @returns TriggerResult
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TriggerResult>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TaskRunResult<TOutput>>;
}
export type AnyTask = Task<any, string, any>;
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["in"]
: TDefault;
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["out"]
: TDefault;
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserIn<TParser>
: never;
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserOut<TParser>
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
? TOutput
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
? TIdentifier
: never;
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
infer TOutput,
infer TIdentifier,
infer TParser
>
? {
id: TIdentifier;
payloadIn: inferParserIn<TParser>;
payloadOut: inferParserOut<TParser>;
output: TOutput;
}
: never;
export type TriggerTaskOptions = {
idempotencyKey?: string;
maxAttempts?: number;
startAt?: Date;
startAfter?: number;
concurrencyKey?: string;
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function task<
TOutput,
TContext extends AnyContextBuilder,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
>(
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
): Task<TOutput, TIdentifier, TParser> {
return createTask(options);
}
export function createTask<
TOutput,
TContext extends AnyContextBuilder,
TIndentifier extends string,
TParser extends Parser | undefined = undefined,
>(
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
): Task<TOutput, TIndentifier, TParser> {
const task: Task<TOutput, TIndentifier, TParser> = {
id: params.id,
trigger: async (payload, options) => {
return {
id: "run_1234",
};
},
triggerAndWait: async (payload, options) => {
const output = await params.run({
meta: { run: "run_1234" },
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
ctx: {} as inferContext<TContext>,
});
return {
ok: true,
id: "run_1234",
output,
};
},
};
return task;
}
export interface TaskLibraryRecord {
[key: string]: AnyTask | TaskLibraryRecord;
}
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
_def: { record: TRecord };
}
export type AnyTaskLibrary = TaskLibrary<any>;
export type CreateTaskLibraryOptions = {
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
};
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
{
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
? $Value extends AnyTask
? $Value
: $Value extends TaskLibrary<infer TRecord>
? TRecord
: $Value extends CreateTaskLibraryOptions
? DecorateCreateTaskLibraryOptions<$Value>
: never
: never;
};
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
input: TInput
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
// TODO: reserved words
return {
_def: {
record: input,
},
};
}
// ======== client side
type DecorateTask<TTask extends AnyTask> = {
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
};
type DecoratedTaskLibraryRecord<
TTaskLibrary extends AnyTaskLibrary,
TRecord extends TaskLibraryRecord,
> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TaskLibraryRecord
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
: $Value extends AnyTask
? DecorateTask<$Value>
: never
: never;
};
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
lib: inferTaskLibraryClient<TTaskLibrary>;
runs: {
retrieve: (id: string) => Promise<{ status: boolean }>;
};
};
export type CreateTriggerClientOptions = {
secretKey?: string;
};
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
options?: CreateTriggerClientOptions
): CreateTriggerClient<TTaskLibrary> {
return {} as CreateTriggerClient<TTaskLibrary>;
}
// trigger/my-tasks.ts
const taskOne = task({
id: "task-1",
run: async () => {
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
return "foo-bar";
},
});
const taskTwo = task({
id: "task-2",
async run(params) {
return {
hello: "world",
payload: params.payload.other,
};
},
});
const userTaskOne = task({
id: "user/task-1",
context: contextBuilder4,
run: async (params) => {
return "foo-bar";
},
});
const userTaskTwo = task({
id: "user/task-2",
context: contextBuilder3,
run: async (params) => {
return "foo-bar";
},
});
const zodTaskOne = task({
id: "zod/task-1",
context: contextBuilder,
schema: z.object({ foo: z.string() }),
run: async (params) => {},
});
const zodTaskTwo = task({
id: "zod/task-2",
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
context: contextBuilder2,
run: async (params) => {
console.log(params.payload.foo, params.meta.run);
},
});
const valibotTaskOne = task({
id: "valibot/task-1",
schema: wrap(
v.object({
foo: v.string(),
})
),
run: async (params) => {
await zodTaskOne.trigger({ foo: "bar" });
await zodTaskTwo.trigger({ foo: "bar" });
await valibotTaskTwo.trigger({ foo: "bar" });
},
});
const valibotTaskTwo = task({
id: "valibot/task-2",
schema: wrap(
v.object({
foo: v.string(),
isAdmin: v.optional(v.boolean(), true),
})
),
run: async (params) => {
await valibotTaskOne.trigger({ foo: "bar" });
},
});
// in trigger/lib.ts
const myTaskLibrary = taskLibrary({
myTasks: { taskOne, taskTwo },
});
const userTaskLibrary = taskLibrary({
userTaskOne,
userTaskTwo,
});
const zodTaskLibrary = taskLibrary({
zodTaskOne,
zodTaskTwo,
});
const valibotTaskLibrary = taskLibrary({
valibotTaskOne,
valibotTaskTwo,
});
export const library = taskLibrary({
foo: myTaskLibrary,
bar: userTaskLibrary,
zod: zodTaskLibrary,
valibot: valibotTaskLibrary,
});
// Export the library type
export type Library = typeof library;
// Now on the client
const client = createTriggerClient<Library>({
secretKey: "tr_dev_1234",
});
client.runs.retrieve("run_12343"); // Call regular API client calls
// Tasks are now available under lib
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
client.lib.bar.userTaskTwo.trigger("user/task-2", {
userId: "user_123",
isAdmin: true,
});
client.lib.bar.userTaskTwo.trigger("user/task-2", {
userId: "user_123",
isAdmin: false,
});
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", {
foo: "bar",
isAdmin: true,
});
+444
View File
@@ -0,0 +1,444 @@
import { z } from "zod";
import * as v from "valibot";
import { wrap } from "@typeschema/valibot";
export type ParserZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type ParserMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type ParserSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type ParserCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type ParserYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type ParserScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type ParserWithoutInput<TInput> =
| ParserCustomValidatorEsque<TInput>
| ParserMyZodEsque<TInput>
| ParserScaleEsque<TInput>
| ParserSuperstructEsque<TInput>
| ParserYupEsque<TInput>;
export type ParserWithInputOutput<TInput, TParsedInput> = ParserZodEsque<TInput, TParsedInput>;
export type Parser = ParserWithInputOutput<any, any> | ParserWithoutInput<any>;
export type inferParser<TParser extends Parser> = TParser extends ParserWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TParser extends ParserWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: TType[K] };
export type TriggerResult = {
id: string;
};
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type RunMetadata = {
run: string;
};
export type RunFnParams<TPayload, TContext extends object> = {
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
meta: RunMetadata;
/** Context added by task middleware */
ctx: TContext;
payload: TPayload;
};
export type TaskOptions<
TOutput,
TContext extends object,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
schema?: TParser;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (params: Simplify<RunFnParams<inferParserOut<TParser>, TContext>>) => Promise<TOutput>;
};
export interface Task<
TOutput,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
* @param options
* @returns TriggerResult
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TriggerResult>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (
payload: Simplify<inferParserIn<TParser, any>>,
options?: TriggerTaskOptions
) => Promise<TaskRunResult<TOutput>>;
}
export type AnyTask = Task<any, string, any>;
type inferParserIn<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["in"]
: TDefault;
type inferParserOut<TParser extends Parser | undefined, TDefault = unknown> = TParser extends Parser
? inferParser<TParser>["out"]
: TDefault;
export type TaskPayloadIn<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserIn<TParser>
: never;
export type TaskPayloadOut<TTask extends AnyTask> = TTask extends Task<any, string, infer TParser>
? inferParserOut<TParser>
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<infer TOutput, string, any>
? TOutput
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<any, infer TIdentifier, any>
? TIdentifier
: never;
export type TaskTypes<TTask extends AnyTask> = TTask extends Task<
infer TOutput,
infer TIdentifier,
infer TParser
>
? {
id: TIdentifier;
payloadIn: inferParserIn<TParser>;
payloadOut: inferParserOut<TParser>;
output: TOutput;
}
: never;
export type TriggerTaskOptions = {
idempotencyKey?: string;
maxAttempts?: number;
startAt?: Date;
startAfter?: number;
concurrencyKey?: string;
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function task<
TOutput,
TContext extends object,
TIdentifier extends string,
TParser extends Parser | undefined = undefined,
>(
options: TaskOptions<TOutput, TContext, TIdentifier, TParser>
): Task<TOutput, TIdentifier, TParser> {
return createTask(options);
}
export function createTask<
TOutput,
TContext extends object,
TIndentifier extends string,
TParser extends Parser | undefined = undefined,
>(
params: TaskOptions<TOutput, TContext, TIndentifier, TParser>
): Task<TOutput, TIndentifier, TParser> {
const task: Task<TOutput, TIndentifier, TParser> = {
id: params.id,
trigger: async (payload, options) => {
return {
id: "run_1234",
};
},
triggerAndWait: async (payload, options) => {
const output = await params.run({
meta: { run: "run_1234" },
payload: payload as unknown as inferParserOut<TParser>, // Actually do the parsing
ctx: {} as TContext,
});
return {
ok: true,
id: "run_1234",
output,
};
},
};
return task;
}
export interface TaskLibraryRecord {
[key: string]: AnyTask | TaskLibraryRecord;
}
export interface TaskLibrary<TRecord extends TaskLibraryRecord> {
_def: { record: TRecord };
}
export type AnyTaskLibrary = TaskLibrary<any>;
export type CreateTaskLibraryOptions = {
[key: string]: AnyTask | AnyTaskLibrary | CreateTaskLibraryOptions;
};
export type DecorateCreateTaskLibraryOptions<TTaskLibraryOptions extends CreateTaskLibraryOptions> =
{
[K in keyof TTaskLibraryOptions]: TTaskLibraryOptions[K] extends infer $Value
? $Value extends AnyTask
? $Value
: $Value extends TaskLibrary<infer TRecord>
? TRecord
: $Value extends CreateTaskLibraryOptions
? DecorateCreateTaskLibraryOptions<$Value>
: never
: never;
};
function taskLibrary<TInput extends CreateTaskLibraryOptions>(
input: TInput
): TaskLibrary<DecorateCreateTaskLibraryOptions<TInput>>;
function taskLibrary<TInput extends TaskLibraryRecord>(input: TInput): TaskLibrary<TInput>;
function taskLibrary(input: TaskLibraryRecord | CreateTaskLibraryOptions) {
// TODO: reserved words
return {
_def: {
record: input,
},
};
}
// ======== client side
type DecorateTask<TTask extends AnyTask> = {
trigger: (id: TaskIdentifier<TTask>, payload: TaskPayloadIn<TTask>) => Promise<{ id: string }>;
};
type DecoratedTaskLibraryRecord<
TTaskLibrary extends AnyTaskLibrary,
TRecord extends TaskLibraryRecord,
> = {
[TKey in keyof TRecord]: TRecord[TKey] extends infer $Value
? $Value extends TaskLibraryRecord
? DecoratedTaskLibraryRecord<TTaskLibrary, $Value>
: $Value extends AnyTask
? DecorateTask<$Value>
: never
: never;
};
export type inferTaskLibraryClient<TTaskLibrary extends AnyTaskLibrary> =
DecoratedTaskLibraryRecord<TTaskLibrary, TTaskLibrary["_def"]["record"]>;
export type CreateTriggerClient<TTaskLibrary extends AnyTaskLibrary> = {
lib: inferTaskLibraryClient<TTaskLibrary>;
runs: {
retrieve: (id: string) => Promise<{ status: boolean }>;
};
};
export type CreateTriggerClientOptions = {
secretKey?: string;
};
export function createTriggerClient<TTaskLibrary extends AnyTaskLibrary>(
options?: CreateTriggerClientOptions
): CreateTriggerClient<TTaskLibrary> {
return {} as CreateTriggerClient<TTaskLibrary>;
}
// trigger/my-tasks.ts
const taskOne = task({
id: "task-1",
run: async () => {
const handle = await taskTwo.trigger({ url: "https://trigger.dev" });
const result = await taskTwo.triggerAndWait({ url: "https://trigger.dev" });
return "foo-bar";
},
});
const taskTwo = task({
id: "task-2",
async run(params) {
return {
hello: "world",
payload: params.payload.other,
};
},
});
const userTaskOne = task({
id: "user/task-1",
run: async (params) => {
return "foo-bar";
},
});
const userTaskTwo = task({
id: "user/task-2",
run: async (params) => {
return "foo-bar";
},
});
const zodTaskOne = task({
id: "zod/task-1",
schema: z.object({ foo: z.string() }),
run: async (params) => {},
});
const zodTaskTwo = task({
id: "zod/task-2",
schema: z.object({ foo: z.string(), isAdmin: z.boolean().default(false) }),
run: async (params) => {
console.log(params.payload.foo, params.meta.run);
},
});
const valibotTaskOne = task({
id: "valibot/task-1",
schema: wrap(
v.object({
foo: v.string(),
})
),
run: async (params) => {
await zodTaskOne.trigger({ foo: "bar" });
await zodTaskTwo.trigger({ foo: "bar" });
await valibotTaskTwo.trigger({ foo: "bar" });
},
});
const valibotTaskTwo = task({
id: "valibot/task-2",
schema: wrap(
v.object({
foo: v.string(),
isAdmin: v.optional(v.boolean(), true),
})
),
run: async (params) => {
await valibotTaskOne.trigger({ foo: "bar" });
},
});
// in trigger/lib.ts
const myTaskLibrary = taskLibrary({
myTasks: { taskOne, taskTwo },
});
const userTaskLibrary = taskLibrary({
userTaskOne,
userTaskTwo,
});
const zodTaskLibrary = taskLibrary({
zodTaskOne,
zodTaskTwo,
});
const valibotTaskLibrary = taskLibrary({
valibotTaskOne,
valibotTaskTwo,
});
export const library = taskLibrary({
foo: myTaskLibrary,
bar: userTaskLibrary,
zod: zodTaskLibrary,
valibot: valibotTaskLibrary,
});
// Export the library type
export type Library = typeof library;
// Now on the client
const client = createTriggerClient<Library>({
secretKey: "tr_dev_1234",
});
client.runs.retrieve("run_12343"); // Call regular API client calls
// Tasks are now available under lib
client.lib.foo.myTasks.taskOne.trigger("task-1", { hello: "world" });
client.lib.bar.userTaskOne.trigger("user/task-1", { userId: "user_123" });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: true });
client.lib.bar.userTaskTwo.trigger("user/task-2", { userId: "user_123", isAdmin: false });
client.lib.zod.zodTaskOne.trigger("zod/task-1", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar" });
client.lib.zod.zodTaskTwo.trigger("zod/task-2", { foo: "bar", isAdmin: false });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar" });
client.lib.valibot.valibotTaskTwo.trigger("valibot/task-2", { foo: "bar", isAdmin: true });

Some files were not shown because too many files have changed in this diff Show More