Alerts v1 (#1065)
* Alerts v1 * Encrypt alert webhook secrets and allow them to be generated by the server * Alert v1 UI * Remove unnecessary emails * Move to using `@react-email/components` * WIP slack alerts * More slack alerts WIP * Update pnpm lock after rebase * Finish implementing Slack alerts * Use a more error like emoji * New secondary variant for the segmented control * Added a simple checkbox style variant to storybook * Style tweak to the segmented control * UI improvements to the alert modal * Use searchable Select for alerts. Changed default variant for SegmentedControl * Secondary button now using secondary colour * segmented control style tweak * Improved the channel column in the alerts table * Updated logo-mono.png * Updated email styles * Email templates updated to new styles * Don’t log the decrypted secret * await enqueing the deployment alert when an index fails * await enqueing the timeout alert --------- Co-authored-by: James Ritchie <james@jamesritchie.co.uk> Co-authored-by: Matt Aitken <matt@mattaitken.com>
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+50
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+397
@@ -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>
|
||||
);
|
||||
}
|
||||
+445
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 |
@@ -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(" ")}`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProjectAlertChannel" ADD COLUMN "enabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ProjectAlertType" ADD VALUE 'DEPLOYMENT_SUCCESS';
|
||||
+9
@@ -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;
|
||||
+8
@@ -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");
|
||||
+31
@@ -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;
|
||||
+16
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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"} can’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>I’m Matt, CEO of Trigger.dev.</Text>
|
||||
<Text style={paragraph}>
|
||||
<Body style={main}>
|
||||
<Text style={paragraphLight}>Hey {name ?? "there"},</Text>
|
||||
<Text style={paragraphLight}>I’m 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 don’t want me to contact you again, please just let me know and I’ll 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"} can’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+1455
-349
File diff suppressed because it is too large
Load Diff
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user