More work on the org template page (added workflows list)
This commit is contained in:
@@ -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 invariant from "tiny-invariant";
|
||||||
import { triggerLabel } from "~/components/triggers/triggerLabel";
|
|
||||||
import type { PrismaClient } from "~/db.server";
|
import type { PrismaClient } from "~/db.server";
|
||||||
import { prisma } 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 { getRuntimeEnvironment } from "./runtimeEnvironment.server";
|
||||||
import type { ExternalSource, Workflow } from "./workflow.server";
|
|
||||||
|
|
||||||
export type WorkflowListItem = Awaited<
|
export type WorkflowListItem = Awaited<
|
||||||
ReturnType<WorkflowListPresenter["data"]>
|
ReturnType<WorkflowListPresenter["data"]>
|
||||||
@@ -38,250 +28,11 @@ export class WorkflowListPresenter {
|
|||||||
});
|
});
|
||||||
invariant(runtimeEnvironment, "Runtime environment not found");
|
invariant(runtimeEnvironment, "Runtime environment not found");
|
||||||
|
|
||||||
const workflows = await getWorkflows(
|
const workflowsPresenter = new WorkflowsPresenter();
|
||||||
this.#prismaClient,
|
|
||||||
organizationSlug,
|
return workflowsPresenter.data(
|
||||||
|
{ organization: { slug: organizationSlug }, isArchived: false },
|
||||||
runtimeEnvironment.id
|
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 type { LoaderArgs } from "@remix-run/server-runtime";
|
||||||
import classNames from "classnames";
|
|
||||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { ApiLogoIcon } from "~/components/code/ApiLogoIcon";
|
|
||||||
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
|
import { CreateNewWorkflow } from "~/components/CreateNewWorkflow";
|
||||||
import { Container } from "~/components/layout/Container";
|
import { Container } from "~/components/layout/Container";
|
||||||
import { List } from "~/components/layout/List";
|
|
||||||
import { PanelInfo } from "~/components/layout/PanelInfo";
|
import { PanelInfo } from "~/components/layout/PanelInfo";
|
||||||
import { PrimaryLink } from "~/components/primitives/Buttons";
|
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 { SubTitle } from "~/components/primitives/text/SubTitle";
|
||||||
import { Title } from "~/components/primitives/text/Title";
|
import { Title } from "~/components/primitives/text/Title";
|
||||||
import { runStatusLabel } from "~/components/runs/runStatus";
|
import { WorkflowList } from "~/components/workflows/workflowList";
|
||||||
import { TriggerTypeIcon } from "~/components/triggers/TriggerIcons";
|
|
||||||
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||||
import { getIntegrationMetadatas } from "~/models/integrations.server";
|
|
||||||
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||||
import type { WorkflowListItem } from "~/models/workflowListPresenter.server";
|
|
||||||
import { WorkflowListPresenter } from "~/models/workflowListPresenter.server";
|
import { WorkflowListPresenter } from "~/models/workflowListPresenter.server";
|
||||||
import { requireUserId } from "~/services/session.server";
|
import { requireUserId } from "~/services/session.server";
|
||||||
import { formatDateTime } from "~/utils";
|
|
||||||
|
|
||||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||||
await requireUserId(request);
|
await requireUserId(request);
|
||||||
invariant(params.organizationSlug, "Organization slug is required");
|
invariant(params.organizationSlug, "Organization slug is required");
|
||||||
|
|
||||||
const providers = getIntegrationMetadatas(false);
|
|
||||||
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
|
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
|
||||||
|
|
||||||
const presenter = new WorkflowListPresenter();
|
const presenter = new WorkflowListPresenter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workflows = await presenter.data(params.organizationSlug, currentEnv);
|
const workflows = await presenter.data(params.organizationSlug, currentEnv);
|
||||||
return typedjson({ workflows, providers });
|
return typedjson({ workflows });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
throw new Response("Error ", { status: 400 });
|
throw new Response("Error ", { status: 400 });
|
||||||
@@ -46,7 +31,7 @@ export const loader = async ({ request, params }: LoaderArgs) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { workflows, providers } = useTypedLoaderData<typeof loader>();
|
const { workflows } = useTypedLoaderData<typeof loader>();
|
||||||
const currentOrganization = useCurrentOrganization();
|
const currentOrganization = useCurrentOrganization();
|
||||||
if (currentOrganization === undefined) {
|
if (currentOrganization === undefined) {
|
||||||
return <></>;
|
return <></>;
|
||||||
@@ -83,163 +68,3 @@ export default function Page() {
|
|||||||
</Container>
|
</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";
|
|
||||||
|
|||||||
+116
-37
@@ -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 { LoaderArgs } from "@remix-run/server-runtime";
|
||||||
import { useTypedLoaderData } from "remix-typedjson";
|
import { useEffect } from "react";
|
||||||
import invariant from "tiny-invariant";
|
import {
|
||||||
|
typedjson,
|
||||||
|
UseDataFunctionReturn,
|
||||||
|
useTypedLoaderData,
|
||||||
|
} from "remix-typedjson";
|
||||||
|
import { useEventSource } from "remix-utils";
|
||||||
|
import { CopyText } from "~/components/CopyText";
|
||||||
import { Container } from "~/components/layout/Container";
|
import { Container } from "~/components/layout/Container";
|
||||||
import { Header1 } from "~/components/primitives/text/Headers";
|
import { Header1 } from "~/components/primitives/text/Headers";
|
||||||
import { prisma } from "~/db.server";
|
import { WorkflowList } from "~/components/workflows/workflowList";
|
||||||
import { useEventSource } from "remix-utils";
|
import { useCurrentOrganization } from "~/hooks/useOrganizations";
|
||||||
import { useRevalidator } from "@remix-run/react";
|
import { getRuntimeEnvironmentFromRequest } from "~/models/runtimeEnvironment.server";
|
||||||
import { useEffect } from "react";
|
import { OrganizationTemplatePresenter } from "~/presenters/organizationTemplatePresenter.server";
|
||||||
import { OrganizationTemplate } from ".prisma/client";
|
|
||||||
|
|
||||||
export async function loader({ params }: LoaderArgs) {
|
export async function loader({ params, request }: LoaderArgs) {
|
||||||
const organizationTemplate = await prisma.organizationTemplate.findUnique({
|
const currentEnv = await getRuntimeEnvironmentFromRequest(request);
|
||||||
where: {
|
|
||||||
id: params.templateId,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
template: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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() {
|
export default function TemplatePage() {
|
||||||
const { organizationTemplate } = useTypedLoaderData<typeof loader>();
|
const loaderData = useTypedLoaderData<typeof loader>();
|
||||||
|
|
||||||
const events = useEventSource(
|
const events = useEventSource(
|
||||||
`/resources/organizationTemplates/${organizationTemplate.id}`
|
`/resources/organizationTemplates/${loaderData.organizationTemplate.id}`
|
||||||
);
|
);
|
||||||
const revalidator = useRevalidator();
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
@@ -38,34 +42,52 @@ export default function TemplatePage() {
|
|||||||
}
|
}
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
|
const organizationTemplateByStatus = (
|
||||||
|
<OrganizationTemplateByStatus {...loaderData} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Header1>{organizationTemplate.template.title}</Header1>
|
<Header1>{loaderData.organizationTemplate.template.title}</Header1>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
{/* Output a loading spinner until the deploy happens */}
|
{organizationTemplateByStatus}
|
||||||
{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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrganizationTemplateReadyToDeploy({
|
function OrganizationTemplateByStatus(loaderData: LoaderData) {
|
||||||
organizationTemplate,
|
if (
|
||||||
}: {
|
loaderData.organizationTemplate.status === "PENDING" ||
|
||||||
organizationTemplate: OrganizationTemplate;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p>Organization Template ready to deploy</p>
|
<p>Organization Template ready to deploy</p>
|
||||||
|
<TemplateHeader organizationTemplate={loaderData.organizationTemplate} />
|
||||||
|
|
||||||
|
<DeploySection {...loaderData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateHeader({
|
||||||
|
organizationTemplate,
|
||||||
|
}: {
|
||||||
|
organizationTemplate: LoaderData["organizationTemplate"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<dt className="font-bold">Repo URL</dt>
|
<dt className="font-bold">Repo URL</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -77,6 +99,63 @@ function OrganizationTemplateReadyToDeploy({
|
|||||||
<dt className="font-bold">Is Private</dt>
|
<dt className="font-bold">Is Private</dt>
|
||||||
<dd>{organizationTemplate.private ? "Yes" : "No"}</dd>
|
<dd>{organizationTemplate.private ? "Yes" : "No"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
id: payload.repository.id,
|
||||||
},
|
},
|
||||||
{}
|
{},
|
||||||
|
{ deliverAfter: 1000 * 10 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -483,6 +483,10 @@ const taskQueueCatalog = {
|
|||||||
data: z.object({ id: z.number() }),
|
data: z.object({ id: z.number() }),
|
||||||
properties: z.object({}),
|
properties: z.object({}),
|
||||||
},
|
},
|
||||||
|
WORKFLOW_CREATED: {
|
||||||
|
data: z.object({ id: z.string() }),
|
||||||
|
properties: z.object({}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createTaskQueue() {
|
function createTaskQueue() {
|
||||||
@@ -837,6 +841,17 @@ function createTaskQueue() {
|
|||||||
|
|
||||||
await service.call(data.id);
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -859,6 +874,7 @@ export { taskQueue, requestTaskQueue, appEventPublisher };
|
|||||||
|
|
||||||
import { ZodEventSubscriber } from "internal-platform";
|
import { ZodEventSubscriber } from "internal-platform";
|
||||||
import { EventEmitter } from "stream";
|
import { EventEmitter } from "stream";
|
||||||
|
import { WorkflowCreated } from "./workflows/events/workflowCreated.server";
|
||||||
|
|
||||||
export async function createEventEmitter({
|
export async function createEventEmitter({
|
||||||
id,
|
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 { Organization } from "~/models/organization.server";
|
||||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||||
import type { Workflow } from "~/models/workflow.server";
|
import type { Workflow } from "~/models/workflow.server";
|
||||||
import { taskQueue } from "../messageBroker.server";
|
import { appEventPublisher, taskQueue } from "../messageBroker.server";
|
||||||
|
|
||||||
export class RegisterWorkflow {
|
export class RegisterWorkflow {
|
||||||
#prismaClient: PrismaClient;
|
#prismaClient: PrismaClient;
|
||||||
@@ -166,12 +166,8 @@ export class RegisterWorkflow {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!existingWorkflow) {
|
if (!existingWorkflow) {
|
||||||
await taskQueue.publish("SEND_INTERNAL_EVENT", {
|
await taskQueue.publish("WORKFLOW_CREATED", {
|
||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: "workflow.created",
|
|
||||||
payload: {
|
|
||||||
id: workflow.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
@@ -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;
|
||||||
@@ -641,6 +641,4 @@ enum OrganizationTemplateStatus {
|
|||||||
CREATED
|
CREATED
|
||||||
READY_TO_DEPLOY
|
READY_TO_DEPLOY
|
||||||
DEPLOYED
|
DEPLOYED
|
||||||
READY_TO_TEST
|
|
||||||
READY_TO_RUN
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ export class TriggerClient<TSchema extends z.ZodTypeAny> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#apiKey = apiKey;
|
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(
|
this.#logger = new Logger(
|
||||||
["trigger.dev", this.#options.id],
|
["trigger.dev", this.#options.id],
|
||||||
this.#options.logLevel
|
this.#options.logLevel
|
||||||
|
|||||||
Reference in New Issue
Block a user