More work on the org template page (added workflows list)

This commit is contained in:
Eric Allam
2023-02-13 17:02:47 +00:00
parent 4ed486ef93
commit 179afbbcb3
14 changed files with 761 additions and 489 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Automatically pickup on the TRIGGER_WSS_URL for the wss endpoint
@@ -0,0 +1,174 @@
import {
ExclamationTriangleIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
import { Link } from "@remix-run/react";
import { Body } from "~/components/primitives/text/Body";
import classNames from "classnames";
import { WorkflowListItem } from "~/models/workflowListPresenter.server";
import { formatDateTime } from "~/utils";
import { ApiLogoIcon } from "../code/ApiLogoIcon";
import { List } from "../layout/List";
import { Header2, Header3 } from "../primitives/text/Headers";
import { runStatusLabel } from "../runs/runStatus";
import { TriggerTypeIcon } from "../triggers/TriggerIcons";
export function WorkflowList({
workflows,
currentOrganizationSlug,
}: {
workflows: WorkflowListItem[];
currentOrganizationSlug: string;
}) {
return (
<List>
{workflows.map((workflow) => {
return (
<li key={workflow.id}>
<Link
to={`/orgs/${currentOrganizationSlug}/workflows/${workflow.slug}`}
className={classNames(
"relative block overflow-hidden transition hover:bg-slate-850/40",
workflow.status === "DISABLED" ? workflowDisabled : ""
)}
>
{workflow.lastRun === undefined && (
<div className="absolute top-2 -right-8 rotate-45 bg-green-700 px-8 py-0.5 text-xs font-semibold uppercase tracking-wide text-green-200 shadow-md">
New
</div>
)}
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
<div className="flex flex-1 items-center justify-between">
<div className="relative flex items-center">
{workflow.status === "CREATED" && (
<ExclamationTriangleIcon className="absolute -top-1.5 -left-1.5 h-6 w-6 text-amber-400" />
)}
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
<TriggerTypeIcon
type={workflow.trigger.type}
provider={workflow.integrations.source}
/>
</div>
<div className="mr-1 flex flex-col gap-1 truncate">
<Header2
size="regular"
className="truncate text-slate-200"
>
{workflow.title}
</Header2>
<div className="flex items-baseline gap-2">
<PillLabel label={workflow.trigger.typeTitle} />
<Header3
size="extra-small"
className="truncate text-slate-400"
>
{workflow.trigger.title}
</Header3>
</div>
<div className="flex flex-wrap items-baseline gap-x-3">
{workflow.trigger.properties &&
workflow.trigger.properties.map((property) => (
<WorkflowProperty
key={property.key}
label={property.key}
content={`${property.value}`}
/>
))}
</div>
</div>
</div>
<ChevronRightIcon
className="ml-5 h-5 w-5 shrink-0 text-slate-400 lg:hidden"
aria-hidden="true"
/>
</div>
<div className="flex flex-grow items-center lg:flex-grow-0">
<div className="mt-2 flex w-full flex-wrap-reverse items-center justify-between gap-3 lg:mt-0 lg:justify-end">
<div className="flex flex-col text-left lg:text-right">
<Body size="extra-small" className="text-slate-500">
Last run: {lastRunDescription(workflow.lastRun)}
</Body>
<Body size="extra-small" className="text-slate-500">
{workflow.slug}
</Body>
</div>
<div className="flex items-center gap-2">
{workflow.integrations.source && (
<ApiLogoIcon
integration={workflow.integrations.source}
size="regular"
/>
)}
{workflow.integrations.services.map((service) => {
if (service === undefined) {
return null;
}
return (
<ApiLogoIcon
size="regular"
key={service.slug}
integration={service}
/>
);
})}
</div>
</div>
<ChevronRightIcon
className="ml-5 hidden h-5 w-5 shrink-0 text-slate-400 lg:block"
aria-hidden="true"
/>
</div>
</div>
</Link>
</li>
);
})}
</List>
);
}
function lastRunDescription(lastRun: WorkflowListItem["lastRun"]) {
if (lastRun === null || lastRun === undefined) {
return "Never";
}
if (lastRun.status === "SUCCESS") {
if (lastRun.finishedAt) {
return formatDateTime(lastRun.finishedAt);
} else {
return "Unknown";
}
}
return runStatusLabel(lastRun.status);
}
function PillLabel({ label }: { label: string }) {
return (
<span className="rounded bg-slate-700 px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{label}
</span>
);
}
function WorkflowProperty({
label,
content,
}: {
label: string;
content: string;
}) {
return (
<div className="flex items-baseline gap-x-1">
<Body size="extra-small" className="uppercase text-slate-500">
{label}
</Body>
<Body size="small" className="truncate text-slate-400">
{content}
</Body>
</div>
);
}
const workflowDisabled = "opacity-30";
@@ -1,18 +1,8 @@
import type { SchedulerSource, InternalSource } from ".prisma/client";
import {
ScheduleSourceSchema,
SlackInteractionSourceSchema,
} from "@trigger.dev/common-schemas";
import cronstrue from "cronstrue";
import type { DisplayProperties } from "@trigger.dev/integration-sdk";
import * as github from "@trigger.dev/github/internal";
import invariant from "tiny-invariant";
import { triggerLabel } from "~/components/triggers/triggerLabel";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getIntegrationMetadata, getIntegrations } from "./integrations.server";
import { WorkflowsPresenter } from "~/presenters/workflowsPresenter.server";
import { getRuntimeEnvironment } from "./runtimeEnvironment.server";
import type { ExternalSource, Workflow } from "./workflow.server";
export type WorkflowListItem = Awaited<
ReturnType<WorkflowListPresenter["data"]>
@@ -38,250 +28,11 @@ export class WorkflowListPresenter {
});
invariant(runtimeEnvironment, "Runtime environment not found");
const workflows = await getWorkflows(
this.#prismaClient,
organizationSlug,
const workflowsPresenter = new WorkflowsPresenter();
return workflowsPresenter.data(
{ organization: { slug: organizationSlug }, isArchived: false },
runtimeEnvironment.id
);
const integrations = getIntegrations(true);
return workflows.map((workflow) => {
const lastRun =
workflow.runs[0] === undefined
? undefined
: {
finishedAt: workflow.runs[0].finishedAt,
status: workflow.runs[0].status,
};
return {
id: workflow.id,
title: workflow.title,
slug: workflow.slug,
status: workflow.status,
trigger: triggerProperties(
workflow,
workflow.externalSource ?? undefined,
workflow.schedulerSources[0] ?? undefined,
workflow.internalSources[0] ?? undefined
),
integrations: {
source: workflow.service
? getIntegrationMetadata(integrations, workflow.service)
: undefined,
services: workflow.externalServices.map((service) =>
getIntegrationMetadata(integrations, service.service)
),
},
lastRun,
};
});
}
}
function getWorkflows(
prismaClient: PrismaClient,
organizationSlug: string,
environmentId: string
) {
return prismaClient.workflow.findMany({
where: { organization: { slug: organizationSlug }, isArchived: false },
include: {
externalServices: {
select: {
service: true,
},
},
externalSource: {
select: {
service: true,
source: true,
},
},
schedulerSources: {
select: {
schedule: true,
},
where: {
environmentId,
},
orderBy: { createdAt: "desc" },
take: 1,
},
internalSources: {
select: {
source: true,
type: true,
},
where: {
environmentId,
},
orderBy: { createdAt: "desc" },
take: 1,
},
runs: {
select: {
finishedAt: true,
status: true,
},
take: 1,
orderBy: { finishedAt: { sort: "desc", nulls: "last" } },
},
},
orderBy: [
{ disabledAt: { sort: "asc", nulls: "first" } },
{ title: "asc" },
],
});
}
function triggerProperties(
workflow: Pick<Workflow, "type" | "eventNames">,
externalSource?: Pick<ExternalSource, "service" | "source">,
schedulerSource?: Pick<SchedulerSource, "schedule">,
internalSource?: Pick<InternalSource, "type" | "source">
): {
type: Workflow["type"];
typeTitle: string;
title: string;
properties?: DisplayProperties["properties"];
} {
switch (workflow.type) {
case "WEBHOOK": {
invariant(externalSource, "External source is required for webhook");
let displayProperties: DisplayProperties;
switch (externalSource.service) {
case "github":
if (github.internalIntegration.webhooks) {
displayProperties =
github.internalIntegration.webhooks?.displayProperties(
externalSource.source
);
} else {
displayProperties = {
title: externalSource.service,
};
}
break;
default:
displayProperties = {
title: externalSource.service,
};
break;
}
return {
type: workflow.type,
typeTitle: "Webhook",
title: displayProperties.title,
properties: displayProperties.properties,
};
}
case "SCHEDULE": {
if (!schedulerSource) {
return {
type: workflow.type,
typeTitle: "Schedule",
title: "Not configured",
};
}
const source = ScheduleSourceSchema.parse(schedulerSource.schedule);
if ("rateOf" in source) {
const unit =
"minutes" in source.rateOf
? source.rateOf.minutes > 1
? "minutes"
: "minute"
: "hours" in source.rateOf
? source.rateOf.hours > 1
? "hours"
: "hour"
: source.rateOf.days > 1
? "days"
: "day";
const value =
"minutes" in source.rateOf
? source.rateOf.minutes
: "hours" in source.rateOf
? source.rateOf.hours
: source.rateOf.days;
return {
type: workflow.type,
typeTitle: "Schedule",
title: `Every ${value} ${unit}`,
};
} else {
return {
type: workflow.type,
typeTitle: "Schedule",
title: cronstrue.toString(source.cron, {
throwExceptionOnParseError: false,
verbose: false,
use24HourTimeFormat: true,
}),
properties: [{ key: "Cron Expression", value: source.cron }],
};
}
}
case "CUSTOM_EVENT":
return {
type: workflow.type,
typeTitle: "Custom event",
title: `on: ${workflow.eventNames.join(", ")}`,
};
case "SLACK_INTERACTION": {
if (!internalSource) {
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: "on: Slack interaction",
};
}
const slackSource = SlackInteractionSourceSchema.safeParse(
internalSource.source
);
if (!slackSource.success) {
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: "on: Slack interaction",
};
}
const title =
slackSource.data.type === "block_action"
? `block_id = ${slackSource.data.blockId}`
: `callback_id = ${slackSource.data.callbackIds.join(", ")}`;
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: title,
properties:
slackSource.data.type === "block_action" &&
slackSource.data.actionIds.length > 0
? [
{
key: "Action ID",
value: slackSource.data.actionIds.join(", "),
},
]
: undefined,
};
}
default: {
return {
type: workflow.type,
typeTitle: triggerLabel(workflow.type),
title: workflow.type,
};
}
}
}
@@ -0,0 +1,55 @@
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { getRuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import { WorkflowsPresenter } from "./workflowsPresenter.server";
export class OrganizationTemplatePresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(templateId: string, environmentSlug: string) {
const organizationTemplate =
await this.#prismaClient.organizationTemplate.findUnique({
where: {
id: templateId,
},
include: {
template: true,
},
});
if (!organizationTemplate) {
throw new Error("Organization template not found");
}
const runtimeEnvironment = await getRuntimeEnvironment({
organizationId: organizationTemplate.organizationId,
slug: environmentSlug,
});
if (!runtimeEnvironment) {
throw new Error("Runtime environment not found");
}
const workflowsPresenter = new WorkflowsPresenter(this.#prismaClient);
const workflows = await workflowsPresenter.data(
{
organizationId: organizationTemplate.organizationId,
slug: {
in: organizationTemplate.template.workflowIds,
},
},
runtimeEnvironment.id
);
return {
organizationTemplate,
apiKey: runtimeEnvironment.apiKey,
workflows,
};
}
}
@@ -0,0 +1,277 @@
import type { SchedulerSource, InternalSource } from ".prisma/client";
import {
ScheduleSourceSchema,
SlackInteractionSourceSchema,
} from "@trigger.dev/common-schemas";
import cronstrue from "cronstrue";
import type { DisplayProperties } from "@trigger.dev/integration-sdk";
import * as github from "@trigger.dev/github/internal";
import invariant from "tiny-invariant";
import { triggerLabel } from "~/components/triggers/triggerLabel";
import type { PrismaClient } from "~/db.server";
import { prisma, Prisma } from "~/db.server";
import {
getIntegrationMetadata,
getIntegrations,
} from "../models/integrations.server";
import type { ExternalSource, Workflow } from "../models/workflow.server";
export type WorkflowListItem = Awaited<
ReturnType<WorkflowsPresenter["data"]>
>[number];
export class WorkflowsPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
async data(whereInput: Prisma.WorkflowWhereInput, environmentId: string) {
const workflows = await getWorkflows(
this.#prismaClient,
whereInput,
environmentId
);
const integrations = getIntegrations(true);
return workflows.map((workflow) => {
const lastRun =
workflow.runs[0] === undefined
? undefined
: {
finishedAt: workflow.runs[0].finishedAt,
status: workflow.runs[0].status,
};
return {
id: workflow.id,
title: workflow.title,
slug: workflow.slug,
status: workflow.status,
trigger: triggerProperties(
workflow,
workflow.externalSource ?? undefined,
workflow.schedulerSources[0] ?? undefined,
workflow.internalSources[0] ?? undefined
),
integrations: {
source: workflow.service
? getIntegrationMetadata(integrations, workflow.service)
: undefined,
services: workflow.externalServices.map((service) =>
getIntegrationMetadata(integrations, service.service)
),
},
lastRun,
};
});
}
}
function getWorkflows(
prismaClient: PrismaClient,
whereInput: Prisma.WorkflowWhereInput,
environmentId: string
) {
return prismaClient.workflow.findMany({
where: whereInput,
include: {
externalServices: {
select: {
service: true,
},
},
externalSource: {
select: {
service: true,
source: true,
},
},
schedulerSources: {
select: {
schedule: true,
},
where: {
environmentId,
},
orderBy: { createdAt: "desc" },
take: 1,
},
internalSources: {
select: {
source: true,
type: true,
},
where: {
environmentId,
},
orderBy: { createdAt: "desc" },
take: 1,
},
runs: {
select: {
finishedAt: true,
status: true,
},
take: 1,
orderBy: { finishedAt: { sort: "desc", nulls: "last" } },
},
},
orderBy: [
{ disabledAt: { sort: "asc", nulls: "first" } },
{ title: "asc" },
],
});
}
function triggerProperties(
workflow: Pick<Workflow, "type" | "eventNames">,
externalSource?: Pick<ExternalSource, "service" | "source">,
schedulerSource?: Pick<SchedulerSource, "schedule">,
internalSource?: Pick<InternalSource, "type" | "source">
): {
type: Workflow["type"];
typeTitle: string;
title: string;
properties?: DisplayProperties["properties"];
} {
switch (workflow.type) {
case "WEBHOOK": {
invariant(externalSource, "External source is required for webhook");
let displayProperties: DisplayProperties;
switch (externalSource.service) {
case "github":
if (github.internalIntegration.webhooks) {
displayProperties =
github.internalIntegration.webhooks?.displayProperties(
externalSource.source
);
} else {
displayProperties = {
title: externalSource.service,
};
}
break;
default:
displayProperties = {
title: externalSource.service,
};
break;
}
return {
type: workflow.type,
typeTitle: "Webhook",
title: displayProperties.title,
properties: displayProperties.properties,
};
}
case "SCHEDULE": {
if (!schedulerSource) {
return {
type: workflow.type,
typeTitle: "Schedule",
title: "Not configured",
};
}
const source = ScheduleSourceSchema.parse(schedulerSource.schedule);
if ("rateOf" in source) {
const unit =
"minutes" in source.rateOf
? source.rateOf.minutes > 1
? "minutes"
: "minute"
: "hours" in source.rateOf
? source.rateOf.hours > 1
? "hours"
: "hour"
: source.rateOf.days > 1
? "days"
: "day";
const value =
"minutes" in source.rateOf
? source.rateOf.minutes
: "hours" in source.rateOf
? source.rateOf.hours
: source.rateOf.days;
return {
type: workflow.type,
typeTitle: "Schedule",
title: `Every ${value} ${unit}`,
};
} else {
return {
type: workflow.type,
typeTitle: "Schedule",
title: cronstrue.toString(source.cron, {
throwExceptionOnParseError: false,
verbose: false,
use24HourTimeFormat: true,
}),
properties: [{ key: "Cron Expression", value: source.cron }],
};
}
}
case "CUSTOM_EVENT":
return {
type: workflow.type,
typeTitle: "Custom event",
title: `on: ${workflow.eventNames.join(", ")}`,
};
case "SLACK_INTERACTION": {
if (!internalSource) {
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: "on: Slack interaction",
};
}
const slackSource = SlackInteractionSourceSchema.safeParse(
internalSource.source
);
if (!slackSource.success) {
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: "on: Slack interaction",
};
}
const title =
slackSource.data.type === "block_action"
? `block_id = ${slackSource.data.blockId}`
: `callback_id = ${slackSource.data.callbackIds.join(", ")}`;
return {
type: workflow.type,
typeTitle: "Slack interaction",
title: title,
properties:
slackSource.data.type === "block_action" &&
slackSource.data.actionIds.length > 0
? [
{
key: "Action ID",
value: slackSource.data.actionIds.join(", "),
},
]
: undefined,
};
}
default: {
return {
type: workflow.type,
typeTitle: triggerLabel(workflow.type),
title: workflow.type,
};
}
}
}
@@ -1,44 +1,29 @@
import {
ChevronRightIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
import classNames from "classnames";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
import { Container } from "~/components/layout/Container";
import { List } from "~/components/layout/List";
import { PanelInfo } from "~/components/layout/PanelInfo";
import { PrimaryLink } from "~/components/primitives/Buttons";
import { Body } from "~/components/primitives/text/Body";
import { Header2, Header3 } from "~/components/primitives/text/Headers";
import { SubTitle } from "~/components/primitives/text/SubTitle";
import { Title } from "~/components/primitives/text/Title";
import { runStatusLabel } from "~/components/runs/runStatus";
import { TriggerTypeIcon } from "~/components/triggers/TriggerIcons";
import { WorkflowList } from "~/components/workflows/workflowList";
import { useCurrentOrganization } from "~/hooks/useOrganizations";
import { getIntegrationMetadatas } from "~/models/integrations.server";
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
import type { WorkflowListItem } from "~/models/workflowListPresenter.server";
import { WorkflowListPresenter } from "~/models/workflowListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { formatDateTime } from "~/utils";
export const loader = async ({ request, params }: LoaderArgs) => {
await requireUserId(request);
invariant(params.organizationSlug, "Organization slug is required");
const providers = getIntegrationMetadatas(false);
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
const presenter = new WorkflowListPresenter();
try {
const workflows = await presenter.data(params.organizationSlug, currentEnv);
return typedjson({ workflows, providers });
return typedjson({ workflows });
} catch (error: any) {
console.error(error);
throw new Response("Error ", { status: 400 });
@@ -46,7 +31,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
};
export default function Page() {
const { workflows, providers } = useTypedLoaderData<typeof loader>();
const { workflows } = useTypedLoaderData<typeof loader>();
const currentOrganization = useCurrentOrganization();
if (currentOrganization === undefined) {
return <></>;
@@ -83,163 +68,3 @@ export default function Page() {
</Container>
);
}
function WorkflowList({
workflows,
currentOrganizationSlug,
}: {
workflows: WorkflowListItem[];
currentOrganizationSlug: string;
}) {
return (
<List>
{workflows.map((workflow) => {
return (
<li key={workflow.id}>
<Link
to={`/orgs/${currentOrganizationSlug}/workflows/${workflow.slug}`}
className={classNames(
"relative block overflow-hidden transition hover:bg-slate-850/40",
workflow.status === "DISABLED" ? workflowDisabled : ""
)}
>
{workflow.lastRun === undefined && (
<div className="absolute top-2 -right-8 rotate-45 bg-green-700 px-8 py-0.5 text-xs font-semibold uppercase tracking-wide text-green-200 shadow-md">
New
</div>
)}
<div className="flex flex-col flex-wrap justify-between py-4 pl-4 pr-4 lg:flex-row lg:flex-nowrap lg:items-center">
<div className="flex flex-1 items-center justify-between">
<div className="relative flex items-center">
{workflow.status === "CREATED" && (
<ExclamationTriangleIcon className="absolute -top-1.5 -left-1.5 h-6 w-6 text-amber-400" />
)}
<div className="mr-4 h-20 w-20 flex-shrink-0 self-start rounded-md bg-slate-850 p-3">
<TriggerTypeIcon
type={workflow.trigger.type}
provider={workflow.integrations.source}
/>
</div>
<div className="mr-1 flex flex-col gap-1 truncate">
<Header2
size="regular"
className="truncate text-slate-200"
>
{workflow.title}
</Header2>
<div className="flex items-baseline gap-2">
<PillLabel label={workflow.trigger.typeTitle} />
<Header3
size="extra-small"
className="truncate text-slate-400"
>
{workflow.trigger.title}
</Header3>
</div>
<div className="flex flex-wrap items-baseline gap-x-3">
{workflow.trigger.properties &&
workflow.trigger.properties.map((property) => (
<WorkflowProperty
key={property.key}
label={property.key}
content={`${property.value}`}
/>
))}
</div>
</div>
</div>
<ChevronRightIcon
className="ml-5 h-5 w-5 shrink-0 text-slate-400 lg:hidden"
aria-hidden="true"
/>
</div>
<div className="flex flex-grow items-center lg:flex-grow-0">
<div className="mt-2 flex w-full flex-wrap-reverse items-center justify-between gap-3 lg:mt-0 lg:justify-end">
<div className="flex flex-col text-left lg:text-right">
<Body size="extra-small" className="text-slate-500">
Last run: {lastRunDescription(workflow.lastRun)}
</Body>
<Body size="extra-small" className="text-slate-500">
{workflow.slug}
</Body>
</div>
<div className="flex items-center gap-2">
{workflow.integrations.source && (
<ApiLogoIcon
integration={workflow.integrations.source}
size="regular"
/>
)}
{workflow.integrations.services.map((service) => {
if (service === undefined) {
return null;
}
return (
<ApiLogoIcon
size="regular"
key={service.slug}
integration={service}
/>
);
})}
</div>
</div>
<ChevronRightIcon
className="ml-5 hidden h-5 w-5 shrink-0 text-slate-400 lg:block"
aria-hidden="true"
/>
</div>
</div>
</Link>
</li>
);
})}
</List>
);
}
function lastRunDescription(lastRun: WorkflowListItem["lastRun"]) {
if (lastRun === null || lastRun === undefined) {
return "Never";
}
if (lastRun.status === "SUCCESS") {
if (lastRun.finishedAt) {
return formatDateTime(lastRun.finishedAt);
} else {
return "Unknown";
}
}
return runStatusLabel(lastRun.status);
}
function PillLabel({ label }: { label: string }) {
return (
<span className="rounded bg-slate-700 px-1.5 py-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{label}
</span>
);
}
function WorkflowProperty({
label,
content,
}: {
label: string;
content: string;
}) {
return (
<div className="flex items-baseline gap-x-1">
<Body size="extra-small" className="uppercase text-slate-500">
{label}
</Body>
<Body size="small" className="truncate text-slate-400">
{content}
</Body>
</div>
);
}
const workflowDisabled = "opacity-30";
@@ -1,34 +1,38 @@
import { ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
import { useRevalidator } from "@remix-run/react";
import { LoaderArgs } from "@remix-run/server-runtime";
import { useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { useEffect } from "react";
import {
typedjson,
UseDataFunctionReturn,
useTypedLoaderData,
} from "remix-typedjson";
import { useEventSource } from "remix-utils";
import { CopyText } from "~/components/CopyText";
import { Container } from "~/components/layout/Container";
import { Header1 } from "~/components/primitives/text/Headers";
import { prisma } from "~/db.server";
import { useEventSource } from "remix-utils";
import { useRevalidator } from "@remix-run/react";
import { useEffect } from "react";
import { OrganizationTemplate } from ".prisma/client";
import { WorkflowList } from "~/components/workflows/workflowList";
import { useCurrentOrganization } from "~/hooks/useOrganizations";
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
import { OrganizationTemplatePresenter } from "~/presenters/organizationTemplatePresenter.server";
export async function loader({ params }: LoaderArgs) {
const organizationTemplate = await prisma.organizationTemplate.findUnique({
where: {
id: params.templateId,
},
include: {
template: true,
},
});
export async function loader({ params, request }: LoaderArgs) {
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
invariant(organizationTemplate, "Template not found");
const presenter = new OrganizationTemplatePresenter();
return { organizationTemplate };
return typedjson(
await presenter.data(params.templateId as string, currentEnv)
);
}
type LoaderData = UseDataFunctionReturn<typeof loader>;
export default function TemplatePage() {
const { organizationTemplate } = useTypedLoaderData<typeof loader>();
const loaderData = useTypedLoaderData<typeof loader>();
const events = useEventSource(
`/resources/organizationTemplates/${organizationTemplate.id}`
`/resources/organizationTemplates/${loaderData.organizationTemplate.id}`
);
const revalidator = useRevalidator();
@@ -38,45 +42,120 @@ export default function TemplatePage() {
}
}, [events]);
const organizationTemplateByStatus = (
<OrganizationTemplateByStatus {...loaderData} />
);
return (
<Container>
<Header1>{organizationTemplate.template.title}</Header1>
<Header1>{loaderData.organizationTemplate.template.title}</Header1>
<br />
{/* Output a loading spinner until the deploy happens */}
{organizationTemplate.status === "CREATED" ? (
<div className="flex justify-center">
<div className="h-32 w-32 animate-spin rounded-full border-b-2 border-slate-50"></div>
</div>
) : (
<OrganizationTemplateReadyToDeploy
organizationTemplate={organizationTemplate}
/>
)}
{organizationTemplateByStatus}
</Container>
);
}
function OrganizationTemplateReadyToDeploy({
organizationTemplate,
}: {
organizationTemplate: OrganizationTemplate;
}) {
function OrganizationTemplateByStatus(loaderData: LoaderData) {
if (
loaderData.organizationTemplate.status === "PENDING" ||
loaderData.organizationTemplate.status === "CREATED"
) {
return (
<div className="flex justify-center">
<div className="h-32 w-32 animate-spin rounded-full border-b-2 border-slate-50"></div>
</div>
);
}
return <OrganizationTemplateReady {...loaderData} />;
}
function OrganizationTemplateReady(loaderData: LoaderData) {
return (
<div>
<p>Organization Template ready to deploy</p>
<TemplateHeader organizationTemplate={loaderData.organizationTemplate} />
<dl className="space-y-2">
<dt className="font-bold">Repo URL</dt>
<dd>
<a href={organizationTemplate.repositoryUrl} target="_blank">
{organizationTemplate.repositoryUrl}
</a>
</dd>
<dt className="font-bold">Is Private</dt>
<dd>{organizationTemplate.private ? "Yes" : "No"}</dd>
</dl>
<DeploySection {...loaderData} />
</div>
);
}
function TemplateHeader({
organizationTemplate,
}: {
organizationTemplate: LoaderData["organizationTemplate"];
}) {
return (
<dl className="space-y-2">
<dt className="font-bold">Repo URL</dt>
<dd>
<a href={organizationTemplate.repositoryUrl} target="_blank">
{organizationTemplate.repositoryUrl}
</a>
</dd>
<dt className="font-bold">Is Private</dt>
<dd>{organizationTemplate.private ? "Yes" : "No"}</dd>
</dl>
);
}
function DeploySection({
organizationTemplate,
apiKey,
workflows,
}: {
organizationTemplate: LoaderData["organizationTemplate"];
apiKey: string;
workflows: LoaderData["workflows"];
}) {
const currentOrganization = useCurrentOrganization();
if (!currentOrganization) {
return null;
}
if (organizationTemplate.status === "READY_TO_DEPLOY") {
return (
<>
<a
href={`https://render.com/deploy?repo=${organizationTemplate.repositoryUrl}`}
target="_blank"
>
<img
src="https://render.com/images/deploy-to-render-button.svg"
alt="Deploy to Render"
/>
</a>
<div className="flex justify-center">
<div className="h-32 w-32 animate-spin rounded-full border-b-2 border-slate-50"></div>
</div>
<div className="relative select-all overflow-hidden rounded-sm border border-slate-800 p-1 pl-2 text-sm text-slate-400">
<span className="pointer-events-none absolute right-7 top-0 block h-6 w-20 bg-gradient-to-r from-transparent to-slate-950"></span>
<CopyText
value={apiKey}
className="group absolute right-0 top-0 flex h-full w-7 items-center justify-center rounded-sm border-l border-slate-800 bg-slate-950 transition hover:cursor-pointer hover:bg-slate-900 active:bg-green-900"
>
<ClipboardDocumentCheckIcon className="h-5 w-5 group-active:text-green-500" />
</CopyText>
{apiKey}
</div>
</>
);
} else {
return (
<>
<div className="flex justify-center">
Deployed, view workflows here:
</div>
<WorkflowList
workflows={workflows}
currentOrganizationSlug={currentOrganization.slug}
/>
</>
);
}
}
@@ -98,7 +98,8 @@ function createWebhooks() {
{
id: payload.repository.id,
},
{}
{},
{ deliverAfter: 1000 * 10 }
);
}
);
@@ -483,6 +483,10 @@ const taskQueueCatalog = {
data: z.object({ id: z.number() }),
properties: z.object({}),
},
WORKFLOW_CREATED: {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
};
function createTaskQueue() {
@@ -837,6 +841,17 @@ function createTaskQueue() {
await service.call(data.id);
return true;
},
WORKFLOW_CREATED: async (id, data, properties, attributes) => {
if (attributes.redeliveryCount >= 4) {
return true;
}
const service = new WorkflowCreated();
await service.call(data.id);
return true;
},
},
@@ -859,6 +874,7 @@ export { taskQueue, requestTaskQueue, appEventPublisher };
import { ZodEventSubscriber } from "internal-platform";
import { EventEmitter } from "stream";
import { WorkflowCreated } from "./workflows/events/workflowCreated.server";
export async function createEventEmitter({
id,
@@ -0,0 +1,76 @@
import { env } from "process";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import { IngestCustomEvent } from "~/services/events/ingestCustomEvent.server";
import { appEventPublisher } from "~/services/messageBroker.server";
export class WorkflowCreated {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const workflow = await this.#prismaClient.workflow.findUnique({
where: { id },
});
if (!workflow) {
return;
}
await this.#sendInternalEvent(workflow.id);
const orgTemplates = await this.#prismaClient.organizationTemplate.findMany(
{
where: {
organizationId: workflow.organizationId,
template: {
workflowIds: {
has: workflow.slug,
},
},
status: "READY_TO_DEPLOY",
},
include: {
template: true,
},
}
);
for (const orgTemplate of orgTemplates) {
await this.#prismaClient.organizationTemplate.update({
where: { id: orgTemplate.id },
data: {
status: "DEPLOYED",
},
});
await appEventPublisher.publish(
"organization-template.updated",
{
id: orgTemplate.id,
status: "DEPLOYED",
},
{
"x-organization-template-id": orgTemplate.id,
}
);
}
}
async #sendInternalEvent(id: string) {
if (!env.INTERNAL_TRIGGER_API_KEY) {
return true;
}
const ingestEventService = new IngestCustomEvent();
await ingestEventService.call({
id,
event: { name: "workflow.created", payload: { id: id } },
apiKey: env.INTERNAL_TRIGGER_API_KEY,
});
}
}
@@ -7,7 +7,7 @@ import { prisma } from "~/db.server";
import type { Organization } from "~/models/organization.server";
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { Workflow } from "~/models/workflow.server";
import { taskQueue } from "../messageBroker.server";
import { appEventPublisher, taskQueue } from "../messageBroker.server";
export class RegisterWorkflow {
#prismaClient: PrismaClient;
@@ -166,12 +166,8 @@ export class RegisterWorkflow {
});
if (!existingWorkflow) {
await taskQueue.publish("SEND_INTERNAL_EVENT", {
await taskQueue.publish("WORKFLOW_CREATED", {
id: workflow.id,
name: "workflow.created",
payload: {
id: workflow.id,
},
});
}
@@ -0,0 +1,16 @@
/*
Warnings:
- The values [READY_TO_TEST,READY_TO_RUN] on the enum `OrganizationTemplateStatus` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "OrganizationTemplateStatus_new" AS ENUM ('PENDING', 'CREATED', 'READY_TO_DEPLOY', 'DEPLOYED');
ALTER TABLE "OrganizationTemplate" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "OrganizationTemplate" ALTER COLUMN "status" TYPE "OrganizationTemplateStatus_new" USING ("status"::text::"OrganizationTemplateStatus_new");
ALTER TYPE "OrganizationTemplateStatus" RENAME TO "OrganizationTemplateStatus_old";
ALTER TYPE "OrganizationTemplateStatus_new" RENAME TO "OrganizationTemplateStatus";
DROP TYPE "OrganizationTemplateStatus_old";
ALTER TABLE "OrganizationTemplate" ALTER COLUMN "status" SET DEFAULT 'PENDING';
COMMIT;
-2
View File
@@ -641,6 +641,4 @@ enum OrganizationTemplateStatus {
CREATED
READY_TO_DEPLOY
DEPLOYED
READY_TO_TEST
READY_TO_RUN
}
+4 -1
View File
@@ -84,7 +84,10 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
}
this.#apiKey = apiKey;
this.#endpoint = this.#options.endpoint ?? "wss://wss.trigger.dev/ws";
this.#endpoint =
this.#options.endpoint ??
process.env.TRIGGER_WSS_URL ??
"wss://wss.trigger.dev/ws";
this.#logger = new Logger(
["trigger.dev", this.#options.id],
this.#options.logLevel