* 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>
This commit is contained in:
Eric Allam
2024-05-09 12:12:00 +01:00
committed by GitHub
parent f90960fc13
commit 0e919f56f2
70 changed files with 5020 additions and 673 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
Better handle uncaught exceptions
@@ -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">
@@ -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"
@@ -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>
</>
+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>;
@@ -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>;
@@ -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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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();
@@ -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 });
}
@@ -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"
@@ -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>
);
}
@@ -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,
};
}
+39
View File
@@ -37,6 +37,9 @@ 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";
const workerCatalog = {
indexEndpoint: z.object({
@@ -136,6 +139,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 = {
@@ -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);
},
},
},
});
}
+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
@@ -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(
+1
View File
@@ -84,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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

+16 -1
View File
@@ -79,6 +79,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({
apiUrl: z.string().optional(),
saveLogs: z.boolean().default(false),
skipUpdateCheck: z.boolean().default(false),
noCache: z.boolean().default(false),
});
type DeployCommandOptions = z.infer<typeof DeployCommandOptions>;
@@ -108,6 +109,12 @@ export function configureDeployCommand(program: Command) {
"Build and load the image using your local Docker. Use the --registry option to specify the registry to push the image to when using --self-hosted, or just use --push-image to push to the default registry."
).hideHelp()
)
.addOption(
new CommandOption(
"--no-cache",
"Do not use the cache when building the image. This will slow down the build process but can be useful if you are experiencing issues with the cache."
).hideHelp()
)
.addOption(
new CommandOption(
"--push",
@@ -291,6 +298,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
buildPlatform: options.buildPlatform,
pushImage: options.push,
selfHostedRegistry: !!options.registry,
noCache: options.noCache,
});
}
@@ -316,6 +324,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
projectRef: resolvedConfig.config.project,
loadImage: options.loadImage,
buildPlatform: options.buildPlatform,
noCache: options.noCache,
},
deploymentSpinner
);
@@ -389,6 +398,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
deploymentResponse.data.id,
{
imageReference,
selfHosted: options.selfHosted,
}
);
@@ -752,6 +762,7 @@ type BuildAndPushImageOptions = {
projectRef: string;
loadImage: boolean;
buildPlatform: string;
noCache: boolean;
};
type BuildAndPushImageResults =
@@ -795,6 +806,7 @@ async function buildAndPushImage(
"build",
"-f",
"Containerfile",
options.noCache ? "--no-cache" : undefined,
"--platform",
options.buildPlatform,
"--provenance",
@@ -920,6 +932,7 @@ async function buildAndPushSelfHostedImage(
"build",
"-f",
"Containerfile",
options.noCache ? "--no-cache" : undefined,
"--platform",
options.buildPlatform,
"--build-arg",
@@ -937,7 +950,9 @@ async function buildAndPushSelfHostedImage(
".", // The build context
].filter(Boolean) as string[];
logger.debug(`docker ${buildArgs.join(" ")}`);
logger.debug(`docker ${buildArgs.join(" ")}`, {
cwd: options.cwd,
});
span.setAttribute("docker.command.build", `docker ${buildArgs.join(" ")}`);
+27
View File
@@ -54,6 +54,33 @@ export function createErrorTaskError(error: TaskRunError): any {
}
}
export function createJsonErrorObject(error: TaskRunError) {
switch (error.type) {
case "BUILT_IN_ERROR": {
return {
name: error.name,
message: error.message,
stackTrace: error.stackTrace,
};
}
case "STRING_ERROR": {
return {
message: error.raw,
};
}
case "CUSTOM_ERROR": {
return {
message: error.raw,
};
}
case "INTERNAL_ERROR": {
return {
message: `trigger.dev internal error (${error.code})`,
};
}
}
}
export function correctErrorStackTrace(
stackTrace: string,
projectDir?: string,
+1
View File
@@ -115,6 +115,7 @@ export type GetEnvironmentVariablesResponseBody = z.infer<
export const StartDeploymentIndexingRequestBody = z.object({
imageReference: z.string(),
selfHosted: z.boolean().optional(),
});
export type StartDeploymentIndexingRequestBody = z.infer<typeof StartDeploymentIndexingRequestBody>;
@@ -0,0 +1,64 @@
-- CreateEnum
CREATE TYPE "ProjectAlertChannelType" AS ENUM ('EMAIL', 'SLACK', 'WEBHOOK');
-- CreateEnum
CREATE TYPE "ProjectAlertType" AS ENUM ('TASK_RUN_ATTEMPT', 'DEPLOYMENT_FAILURE');
-- CreateEnum
CREATE TYPE "ProjectAlertStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
-- CreateTable
CREATE TABLE "ProjectAlertChannel" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"type" "ProjectAlertChannelType" NOT NULL,
"name" TEXT NOT NULL,
"properties" JSONB NOT NULL,
"alertTypes" "ProjectAlertType"[],
"projectId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectAlertChannel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectAlert" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"environmentId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"status" "ProjectAlertStatus" NOT NULL DEFAULT 'PENDING',
"type" "ProjectAlertType" NOT NULL,
"taskRunAttemptId" TEXT,
"workerDeploymentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectAlert_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAlertChannel_friendlyId_key" ON "ProjectAlertChannel"("friendlyId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAlert_friendlyId_key" ON "ProjectAlert"("friendlyId");
-- AddForeignKey
ALTER TABLE "ProjectAlertChannel" ADD CONSTRAINT "ProjectAlertChannel_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ProjectAlertChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_taskRunAttemptId_fkey" FOREIGN KEY ("taskRunAttemptId") REFERENCES "TaskRunAttempt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlert" ADD CONSTRAINT "ProjectAlert_workerDeploymentId_fkey" FOREIGN KEY ("workerDeploymentId") REFERENCES "WorkerDeployment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ProjectAlertChannel" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ProjectAlertType" ADD VALUE 'DEPLOYMENT_SUCCESS';
@@ -0,0 +1,9 @@
/*
Warnings:
- The required column `deduplicationKey` was added to the `ProjectAlertChannel` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- AlterTable
ALTER TABLE "ProjectAlertChannel" ADD COLUMN "deduplicationKey" TEXT NOT NULL,
ADD COLUMN "userProvidedDeduplicationKey" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[projectId,deduplicationKey]` on the table `ProjectAlertChannel` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ProjectAlertChannel_projectId_deduplicationKey_key" ON "ProjectAlertChannel"("projectId", "deduplicationKey");
@@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "IntegrationService" AS ENUM ('SLACK');
-- AlterTable
ALTER TABLE "ProjectAlertChannel" ADD COLUMN "integrationId" TEXT;
-- CreateTable
CREATE TABLE "OrganizationIntegration" (
"id" TEXT NOT NULL,
"friendlyId" TEXT NOT NULL,
"service" "IntegrationService" NOT NULL,
"integrationData" JSONB,
"tokenReferenceId" TEXT,
"organizationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationIntegration_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OrganizationIntegration_friendlyId_key" ON "OrganizationIntegration"("friendlyId");
-- AddForeignKey
ALTER TABLE "ProjectAlertChannel" ADD CONSTRAINT "ProjectAlertChannel_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "OrganizationIntegration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey" FOREIGN KEY ("tokenReferenceId") REFERENCES "SecretReference"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
/*
Warnings:
- Made the column `integrationData` on table `OrganizationIntegration` required. This step will fail if there are existing NULL values in that column.
- Made the column `tokenReferenceId` on table `OrganizationIntegration` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "OrganizationIntegration" DROP CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey";
-- AlterTable
ALTER TABLE "OrganizationIntegration" ALTER COLUMN "integrationData" SET NOT NULL,
ALTER COLUMN "tokenReferenceId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "OrganizationIntegration" ADD CONSTRAINT "OrganizationIntegration_tokenReferenceId_fkey" FOREIGN KEY ("tokenReferenceId") REFERENCES "SecretReference"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "ProjectAlertStorage" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"alertChannelId" TEXT NOT NULL,
"alertType" "ProjectAlertType" NOT NULL,
"storageId" TEXT NOT NULL,
"storageData" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectAlertStorage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ProjectAlertStorage" ADD CONSTRAINT "ProjectAlertStorage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectAlertStorage" ADD CONSTRAINT "ProjectAlertStorage_alertChannelId_fkey" FOREIGN KEY ("alertChannelId") REFERENCES "ProjectAlertChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+135 -8
View File
@@ -130,12 +130,13 @@ model Organization {
events EventRecord[]
jobRuns JobRun[]
projects Project[]
members OrgMember[]
invites OrgMemberInvite[]
externalAccounts ExternalAccount[]
integrations Integration[]
sources TriggerSource[]
projects Project[]
members OrgMember[]
invites OrgMemberInvite[]
externalAccounts ExternalAccount[]
integrations Integration[]
sources TriggerSource[]
organizationIntegrations OrganizationIntegration[]
}
model ExternalAccount {
@@ -401,6 +402,7 @@ model RuntimeEnvironment {
taskRunAttempts TaskRunAttempt[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskScheduleInstances TaskScheduleInstance[]
alerts ProjectAlert[]
sessions RuntimeEnvironmentSession[]
currentSession RuntimeEnvironmentSession? @relation("currentSession", fields: [currentSessionId], references: [id], onDelete: SetNull, onUpdate: Cascade)
@@ -452,6 +454,9 @@ model Project {
WorkerDeployment WorkerDeployment[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
taskSchedules TaskSchedule[]
alertChannels ProjectAlertChannel[]
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
}
enum ProjectVersion {
@@ -1129,8 +1134,9 @@ model SecretReference {
httpEndpoints TriggerHttpEndpoint[]
environmentVariableValues EnvironmentVariableValue[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
OrganizationIntegration OrganizationIntegration[]
}
enum SecretStoreProvider {
@@ -1764,6 +1770,7 @@ model TaskRunAttempt {
checkpoints Checkpoint[]
batchTaskRunItems BatchTaskRunItem[]
CheckpointRestoreEvent CheckpointRestoreEvent[]
alerts ProjectAlert[]
@@unique([taskRunId, number])
}
@@ -2105,6 +2112,7 @@ model WorkerDeployment {
updatedAt DateTime @updatedAt
promotions WorkerDeploymentPromotion[]
alerts ProjectAlert[]
@@unique([projectId, shortCode])
@@unique([environmentId, version])
@@ -2218,3 +2226,122 @@ model RuntimeEnvironmentSession {
currentEnvironments RuntimeEnvironment[] @relation("currentSession")
}
model ProjectAlertChannel {
id String @id @default(cuid())
friendlyId String @unique
///can be provided and we won't create another with the same key
deduplicationKey String @default(cuid())
userProvidedDeduplicationKey Boolean @default(false)
integration OrganizationIntegration? @relation(fields: [integrationId], references: [id], onDelete: SetNull, onUpdate: Cascade)
integrationId String?
enabled Boolean @default(true)
type ProjectAlertChannelType
name String
properties Json
alertTypes ProjectAlertType[]
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
alerts ProjectAlert[]
alertStorages ProjectAlertStorage[]
@@unique([projectId, deduplicationKey])
}
enum ProjectAlertChannelType {
EMAIL
SLACK
WEBHOOK
}
model ProjectAlert {
id String @id @default(cuid())
friendlyId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
environmentId String
channel ProjectAlertChannel @relation(fields: [channelId], references: [id], onDelete: Cascade, onUpdate: Cascade)
channelId String
status ProjectAlertStatus @default(PENDING)
type ProjectAlertType
taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade)
taskRunAttemptId String?
workerDeployment WorkerDeployment? @relation(fields: [workerDeploymentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
workerDeploymentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ProjectAlertType {
TASK_RUN_ATTEMPT
DEPLOYMENT_FAILURE
DEPLOYMENT_SUCCESS
}
enum ProjectAlertStatus {
PENDING
SENT
FAILED
}
model ProjectAlertStorage {
id String @id @default(cuid())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String
alertChannel ProjectAlertChannel @relation(fields: [alertChannelId], references: [id], onDelete: Cascade, onUpdate: Cascade)
alertChannelId String
alertType ProjectAlertType
storageId String
storageData Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OrganizationIntegration {
id String @id @default(cuid())
friendlyId String @unique
service IntegrationService
integrationData Json
tokenReference SecretReference @relation(fields: [tokenReferenceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
tokenReferenceId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
alertChannels ProjectAlertChannel[]
}
enum IntegrationService {
SLACK
}
@@ -0,0 +1,87 @@
import {
Body,
CodeBlock,
Container,
Head,
Html,
Link,
Preview,
Text,
dracula,
} from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight, paragraphTight } from "./components/styles";
export const AlertAttemptEmailSchema = z.object({
email: z.literal("alert-attempt"),
taskIdentifier: z.string(),
fileName: z.string(),
exportName: z.string(),
version: z.string(),
environment: z.string(),
error: z.object({
message: z.string(),
name: z.string().optional(),
stackTrace: z.string().optional(),
}),
attemptLink: z.string().url(),
});
const previewDefaults = {
taskIdentifier: "my-task",
fileName: "other.ts",
exportName: "myTask",
version: "20240101.1",
environment: "prod",
error: {
message: "Error message",
name: "Error name",
stackTrace: "Error stack trace",
},
attemptLink: "https://trigger.dev",
};
export default function Email(props: z.infer<typeof AlertAttemptEmailSchema>) {
const { taskIdentifier, fileName, exportName, version, environment, error, attemptLink } = {
...previewDefaults,
...props,
};
return (
<Html>
<Head />
<Preview>{`[${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>There's been an error on `{taskIdentifier}`</Text>
<Text style={paragraphTight}>Task ID: {taskIdentifier}</Text>
<Text style={paragraphTight}>Filename: {fileName}</Text>
<Text style={paragraphTight}>Function: {exportName}()</Text>
<Text style={paragraphTight}>Version: {version}</Text>
<Text style={paragraphTight}>Environment: {environment}</Text>
<Text style={paragraphLight}>{error.message}</Text>
{error.stackTrace && (
<CodeBlock code={error.stackTrace} theme={dracula} lineNumbers language="log" />
)}
<Link
href={attemptLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "32px",
}}
>
Investigate this error
</Link>
<Image path="/emails/logo-mono.png" width="156" height="28" alt="Trigger.dev" />
<Footer />
</Container>
</Body>
</Html>
);
}
+6 -11
View File
@@ -1,15 +1,10 @@
import React from "react";
// Use a global variable to store the base path
let globalBasePath: string = "http://localhost:3000";
type BasePathContext = { basePath: string };
const Context = React.createContext<BasePathContext>({
basePath: "http://localhost:3000",
});
export function BasePath({ basePath, children }: { basePath: string; children: React.ReactNode }) {
return <Context.Provider value={{ basePath }}>{children}</Context.Provider>;
export function setGlobalBasePath(basePath: string) {
globalBasePath = basePath;
}
export function useBasePath() {
return React.useContext(Context).basePath;
export function getGlobalBasePath() {
return globalBasePath;
}
+1 -3
View File
@@ -1,6 +1,4 @@
import { Hr } from "@react-email/hr";
import { Link } from "@react-email/link";
import { Text } from "@react-email/text";
import { Hr, Link, Text } from "@react-email/components";
import React from "react";
import { footer, footerAnchor, hr } from "./styles";
+4 -3
View File
@@ -1,12 +1,13 @@
import { Img } from "@react-email/img";
import { Img } from "@react-email/components";
import * as React from "react";
import { useBasePath } from "./BasePath";
import { getGlobalBasePath } from "./BasePath";
type ImageProps = Omit<Parameters<typeof Img>[0], "src"> & {
path: string;
};
export function Image({ path, ...props }: ImageProps) {
const basePath = useBasePath();
const basePath = getGlobalBasePath();
return <Img src={`${basePath}${path}`} {...props} />;
}
+22 -13
View File
@@ -1,5 +1,5 @@
export const h1 = {
color: "#333",
color: "#D7D9DD",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "24px",
@@ -9,11 +9,11 @@ export const h1 = {
};
export const main = {
backgroundColor: "#ffffff",
backgroundColor: "#15171A",
};
export const container = {
backgroundColor: "#ffffff",
backgroundColor: "#15171A",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
@@ -24,12 +24,12 @@ export const box = {
};
export const hr = {
borderColor: "#e6ebf1",
borderColor: "#272A2E",
margin: "20px 0",
};
export const paragraph = {
color: "#333",
color: "#878C99",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
@@ -38,7 +38,7 @@ export const paragraph = {
};
export const paragraphLight = {
color: "#979797",
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
@@ -46,8 +46,17 @@ export const paragraphLight = {
textAlign: "left" as const,
};
export const paragraphTight = {
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
lineHeight: "16px",
textAlign: "left" as const,
};
export const bullets = {
color: "#333",
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
@@ -57,7 +66,7 @@ export const bullets = {
};
export const anchor = {
color: "#2754C5",
color: "#826DFF",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "16px",
@@ -65,9 +74,9 @@ export const anchor = {
};
export const button = {
backgroundColor: "#656ee8",
backgroundColor: "#826DFF",
borderRadius: "5px",
color: "#fff",
color: "#D7D9DD",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "16px",
@@ -78,7 +87,7 @@ export const button = {
};
export const footer = {
color: "#8898aa",
color: "#878C99",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
fontSize: "12px",
@@ -86,7 +95,7 @@ export const footer = {
};
export const footerItalic = {
color: "#8898aa",
color: "#878C99",
fontStyle: "italic",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
@@ -95,7 +104,7 @@ export const footerItalic = {
};
export const footerAnchor = {
color: "#2754C5",
color: "#826DFF",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "12px",
@@ -1,40 +0,0 @@
import { Button } from "@react-email/button";
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Footer } from "./components/Footer";
import { main, paragraph, button } from "./components/styles";
export default function Email({
name,
integration = "Slack",
workflowId = "t35t",
}: {
name?: string;
workflowId: string;
integration: string;
}) {
return (
<Html>
<Head />
<Section style={main}>
<Text style={paragraph}>
Your workflow {workflowId ?? "WORKFLOWID"} cant run because you need to connect to{" "}
{integration ?? "INTEGRATION"}.
</Text>
<Text style={paragraph}>To fix this, just click the button below.</Text>
<Button href="" pX={20} pY={12} style={button}>
Connect {integration ?? "INTEGRATION"}
</Button>
<Text style={paragraph}> The Trigger.dev team</Text>
<Footer />
</Section>
</Html>
);
}
@@ -0,0 +1,80 @@
import {
Body,
CodeBlock,
Container,
Head,
Html,
Link,
Preview,
Text,
dracula,
} from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
export const AlertDeploymentFailureEmailSchema = z.object({
email: z.literal("alert-deployment-failure"),
version: z.string(),
environment: z.string(),
shortCode: z.string(),
failedAt: z.date(),
error: z.object({
name: z.string(),
message: z.string(),
stack: z.string().optional(),
}),
deploymentLink: z.string().url(),
});
const previewDefaults = {
version: "v1",
environment: "production",
shortCode: "abc123",
failedAt: new Date().toISOString(),
error: {
name: "Error",
stack: "Error: Something went wrong\n at main.ts:12:34",
},
deploymentLink: "https://trigger.dev",
};
export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailSchema>) {
const { version, environment, shortCode, failedAt, error, deploymentLink } = {
...previewDefaults,
...props,
};
return (
<Html>
<Head />
<Preview>{`Deployment ${version} [${environment}] failed: ${error.name}`}</Preview>
<Body style={main}>
<Container style={container}>
<Text style={h1}>{`An error occurred deploying ${version} in ${environment}`}</Text>
<Text style={paragraphLight}>
{error.name} {error.message}
</Text>
{error.stack && (
<CodeBlock code={error.stack} theme={dracula} lineNumbers language="log" />
)}
<Link
href={deploymentLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "32px",
}}
>
Investigate this error
</Link>
<Image path="/emails/logo-mono.png" width="156" height="28" alt="Trigger.dev" />
<Footer />
</Container>
</Body>
</Html>
);
}
@@ -0,0 +1,60 @@
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main } from "./components/styles";
export const AlertDeploymentSuccessEmailSchema = z.object({
email: z.literal("alert-deployment-success"),
version: z.string(),
environment: z.string(),
shortCode: z.string(),
deployedAt: z.date(),
taskCount: z.number(),
deploymentLink: z.string().url(),
});
const previewDefaults = {
version: "v1",
environment: "production",
shortCode: "abc123",
deployedAt: new Date().toISOString(),
taskCount: 3,
deploymentLink: "https://trigger.dev",
};
export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailSchema>) {
const { version, environment, shortCode, deployedAt, taskCount, deploymentLink } = {
...previewDefaults,
...props,
};
return (
<Html>
<Head />
<Preview>{`Deployment ${version} [${environment}] succeeded`}</Preview>
<Body style={main}>
<Container style={container}>
<Text
style={h1}
>{`Version ${version} successfully deployed ${taskCount} tasks in ${environment}`}</Text>
<Link
href={deploymentLink}
target="_blank"
style={{
...anchor,
display: "block",
marginBottom: "32px",
}}
>
View Deployment
</Link>
<Image path="/emails/logo-mono.png" width="156" height="28" alt="Trigger.dev" />
<Footer />
</Container>
</Body>
</Html>
);
}
+7 -14
View File
@@ -1,15 +1,8 @@
import { Container } from "@react-email/container";
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Image } from "./components/Image";
import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Footer } from "./components/Footer";
import { main, anchor, h1, container, paragraphLight } from "./components/styles";
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import { z } from "zod";
import { Footer } from "./components/Footer";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraphLight } from "./components/styles";
export const InviteEmailSchema = z.object({
email: z.literal("invite"),
@@ -29,7 +22,7 @@ export default function Email({
<Html>
<Head />
<Preview>{`You've been invited to ${orgName}`}</Preview>
<Section style={main}>
<Body style={main}>
<Container style={container}>
<Text style={h1}>{`You've been invited to ${orgName}`}</Text>
<Text style={paragraphLight}>
@@ -41,7 +34,7 @@ export default function Email({
style={{
...anchor,
display: "block",
marginBottom: "16px",
marginBottom: "32px",
}}
>
Click here to view the invitation
@@ -50,7 +43,7 @@ export default function Email({
<Image path="/emails/logo-mono.png" width="156" height="28" alt="Trigger.dev" />
<Footer />
</Container>
</Section>
</Body>
</Html>
);
}
+7 -14
View File
@@ -1,21 +1,14 @@
import { Container } from "@react-email/container";
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Image } from "./components/Image";
import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Body, Container, Head, Html, Link, Preview, Text } from "@react-email/components";
import { Footer } from "./components/Footer";
import { main, anchor, h1, container, paragraphLight } from "./components/styles";
import { Image } from "./components/Image";
import { anchor, container, h1, main, paragraph } from "./components/styles";
export default function Email({ magicLink }: { magicLink: string }) {
return (
<Html>
<Head />
<Preview>Log in with this magic link 🪄</Preview>
<Section style={main}>
<Body style={main}>
<Container style={container}>
<Text style={h1}>Log in to Trigger.dev</Text>
<Link
@@ -24,18 +17,18 @@ export default function Email({ magicLink }: { magicLink: string }) {
style={{
...anchor,
display: "block",
marginBottom: "16px",
marginBottom: "32px",
}}
>
Click here to log in with this magic link
</Link>
<Text style={paragraphLight}>
<Text style={paragraph}>
If you didn&apos;t try to log in, you can safely ignore this email.
</Text>
<Image path="/emails/logo-mono.png" width="156" height="28" alt="Trigger.dev" />
<Footer />
</Container>
</Section>
</Body>
</Html>
);
}
+11 -17
View File
@@ -1,26 +1,20 @@
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Link } from "@react-email/link";
import { Preview } from "@react-email/preview";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Body, Head, Html, Link, Preview, Section, Text } from "@react-email/components";
import { Footer } from "./components/Footer";
import { anchor, bullets, footerItalic, main, paragraph } from "./components/styles";
import { anchor, bullets, footerItalic, main, paragraphLight } from "./components/styles";
export default function Email({ name }: { name?: string }) {
return (
<Html>
<Head />
<Preview>Power up your workflows</Preview>
<Section style={main}>
<Text style={paragraph}>Hey {name ?? "there"},</Text>
<Text style={paragraph}>Im Matt, CEO of Trigger.dev.</Text>
<Text style={paragraph}>
<Body style={main}>
<Text style={paragraphLight}>Hey {name ?? "there"},</Text>
<Text style={paragraphLight}>Im Matt, CEO of Trigger.dev.</Text>
<Text style={paragraphLight}>
Our goal is to give developers like you the ability to effortlessly create powerful
workflows in code.
</Text>
<Text style={paragraph}>
<Text style={paragraphLight}>
We recommend{" "}
<Link style={anchor} href="https://app.trigger.dev/templates">
getting started with one of our templates
@@ -29,7 +23,7 @@ export default function Email({ name }: { name?: string }) {
workflows.
</Text>
<Text style={paragraph}>
<Text style={paragraphLight}>
Feel free to reply to me if you have any questions. You can also{" "}
<Link style={anchor} href="https://cal.com/team/triggerdotdev/call">
schedule a call
@@ -41,17 +35,17 @@ export default function Email({ name }: { name?: string }) {
to connect with the community and our team.
</Text>
<Text style={paragraph}>We hope you enjoy using Trigger.dev!</Text>
<Text style={paragraphLight}>We hope you enjoy using Trigger.dev!</Text>
<Text style={bullets}>Best,</Text>
<Text style={bullets}>Matt</Text>
<Text style={paragraph}>CEO, Trigger.dev</Text>
<Text style={paragraphLight}>CEO, Trigger.dev</Text>
<Text style={footerItalic}>
If you dont want me to contact you again, please just let me know and Ill update your
preferences.
</Text>
<Footer />
</Section>
</Body>
</Html>
);
}
@@ -1,35 +0,0 @@
import { Button } from "@react-email/button";
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Footer } from "./components/Footer";
import { main, paragraph, button } from "./components/styles";
export default function Email({
name,
workflowId = "t35t",
}: {
name?: string;
workflowId: string;
}) {
return (
<Html>
<Head />
<Section style={main}>
<Text style={paragraph}>Your workflow, {workflowId ?? "WORKFLOWID"} has failed.</Text>
<Text style={paragraph}>To learn more, just click the button below.</Text>
<Button href="" pX={20} pY={12} style={button}>
View the issue
</Button>
<Text style={paragraph}> The Trigger.dev team</Text>
<Footer />
</Section>
</Html>
);
}
@@ -1,40 +0,0 @@
import { Button } from "@react-email/button";
import { Head } from "@react-email/head";
import { Html } from "@react-email/html";
import { Section } from "@react-email/section";
import { Text } from "@react-email/text";
import * as React from "react";
import { Footer } from "./components/Footer";
import { main, paragraph, button } from "./components/styles";
export default function Email({
name,
integration = "Slack",
workflowId = "t35t",
}: {
name?: string;
workflowId: string;
integration: string;
}) {
return (
<Html>
<Head />
<Section style={main}>
<Text style={paragraph}>
Your workflow {workflowId ?? "WORKFLOWID"} cant run because it requires{" "}
{integration ?? "INTEGRATION"} to be connected.
</Text>
<Text style={paragraph}>To get back up and running, just click the button below.</Text>
<Button href="" pX={20} pY={12} style={button}>
Connect {integration ?? "INTEGRATION"}
</Button>
<Text style={paragraph}> The Trigger.dev team</Text>
<Footer />
</Section>
</Html>
);
}
+3 -13
View File
@@ -9,20 +9,10 @@
"dev": "PORT=3080 email dev"
},
"dependencies": {
"@react-email/button": "^0.0.4",
"@react-email/container": "^0.0.4",
"@react-email/head": "^0.0.2",
"@react-email/heading": "^0.0.5",
"@react-email/hr": "^0.0.2",
"@react-email/html": "^0.0.2",
"@react-email/img": "^0.0.2",
"@react-email/link": "^0.0.2",
"@react-email/preview": "^0.0.2",
"@react-email/render": "^0.0.7",
"@react-email/section": "^0.0.1",
"@react-email/text": "^0.0.2",
"@react-email/components": "0.0.16",
"@react-email/render": "^0.0.12",
"react": "^18.2.0",
"react-email": "^1.6.1",
"react-email": "^2.1.1",
"resend": "^3.2.0",
"tiny-invariant": "^1.2.0",
"zod": "3.22.3"
+30 -37
View File
@@ -1,16 +1,19 @@
import { ReactElement } from "react";
import { BasePath } from "../emails/components/BasePath";
import WelcomeEmail from "../emails/welcome";
import MagicLinkEmail from "../emails/magic-link";
import ConnectIntegration from "../emails/connect-integration";
import WorkflowFailed from "../emails/workflow-failed";
import WorkflowIntegration from "../emails/workflow-integration";
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
import { render } from "@react-email/render";
import { ReactElement } from "react";
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
import { setGlobalBasePath } from "../emails/components/BasePath";
import AlertDeploymentFailureEmail, {
AlertDeploymentFailureEmailSchema,
} from "../emails/deployment-failure";
import AlertDeploymentSuccessEmail, {
AlertDeploymentSuccessEmailSchema,
} from "../emails/deployment-success";
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
import MagicLinkEmail from "../emails/magic-link";
import WelcomeEmail from "../emails/welcome";
import { Resend } from "resend";
import { z } from "zod";
import React from "react";
export const DeliverEmailSchema = z
.discriminatedUnion("email", [
@@ -23,20 +26,9 @@ export const DeliverEmailSchema = z
magicLink: z.string().url(),
}),
InviteEmailSchema,
z.object({
email: z.literal("connect_integration"),
workflowId: z.string(),
integration: z.string(),
}),
z.object({
email: z.literal("workflow_failed"),
workflowId: z.string(),
}),
z.object({
email: z.literal("workflow_integration"),
workflowId: z.string(),
integration: z.string(),
}),
AlertAttemptEmailSchema,
AlertDeploymentFailureEmailSchema,
AlertDeploymentSuccessEmailSchema,
])
.and(z.object({ to: z.string() }));
@@ -61,10 +53,12 @@ export class EmailClient {
async send(data: DeliverEmail) {
const { subject, component } = this.#getTemplate(data);
setGlobalBasePath(this.#imagesBaseUrl);
return this.#sendEmail({
to: data.to,
subject,
react: <BasePath basePath={this.#imagesBaseUrl}>{component}</BasePath>,
react: component,
});
}
@@ -102,25 +96,24 @@ export class EmailClient {
subject: `You've been invited to join ${data.orgName} on Trigger.dev`,
component: <InviteEmail {...data} />,
};
case "connect_integration":
case "alert-attempt": {
return {
subject: `Action required: you need to connect to ${data.integration}`,
component: (
<ConnectIntegration workflowId={data.workflowId} integration={data.integration} />
),
subject: `Error on ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
component: <AlertAttemptFailureEmail {...data} />,
};
case "workflow_failed":
}
case "alert-deployment-failure": {
return {
subject: "Action required: your workflow has stopped running!",
component: <WorkflowFailed workflowId={data.workflowId} />,
subject: `Deployment ${data.version} [${data.environment}] failed: ${data.error.name}`,
component: <AlertDeploymentFailureEmail {...data} />,
};
case "workflow_integration":
}
case "alert-deployment-success": {
return {
subject: `Action required: connect ${data.integration} to start your workflow`,
component: (
<WorkflowIntegration workflowId={data.workflowId} integration={data.integration} />
),
subject: `Deployment ${data.version} [${data.environment}] succeeded`,
component: <AlertDeploymentSuccessEmail {...data} />,
};
}
}
}
+1455 -349
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -49,10 +49,18 @@ export const taskWithRetries = task({
export const taskThatErrors = task({
id: "task-that-errors",
run: async (payload: any, { ctx }) => {
throw new Error("failed");
connectToDatabase();
},
});
function connectToDatabase() {
initializeConnection();
}
function initializeConnection() {
throw new Error("Access denied. You do not have the necessary permissions.");
}
export const taskWithFetchRetries = task({
id: "task-with-fetch-retries",
middleware: (payload: any, { next }) => {
+2 -2
View File
@@ -9,7 +9,7 @@ export const simplestTask = task({
body: JSON.stringify({
hello: "world",
taskId: "fetch-post-task",
foo: "barrrrrrrrrrrrrrrrrrr",
foo: "barrrrrrrrrrrrrrrrrrrr",
}),
});
@@ -126,7 +126,7 @@ export const childTask = task({
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "childTask payload and ctx",
title: "childTask payload and ctxr",
content: {
payload,
ctx,
+2 -3
View File
@@ -16,8 +16,8 @@ export const config: TriggerConfig = {
randomize: true,
},
},
additionalPackages: ["wrangler@3.35.0", "pg@8.11.5", "prisma@5.11.0"],
additionalFiles: ["./wrangler/wrangler.toml", "./prisma/schema.prisma"],
additionalPackages: ["wrangler@3.35.0", "pg@8.11.5"],
additionalFiles: ["./wrangler/wrangler.toml"],
dependenciesToBundle: [/@sindresorhus/, "escape-string-regexp"],
instrumentations: [new OpenAIInstrumentation()],
logLevel: "info",
@@ -33,5 +33,4 @@ export const config: TriggerConfig = {
throw error;
},
postInstall: "npm exec --package prisma -- prisma generate",
};