Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70f9bd0d70 | |||
| a1860dbaae | |||
| d69e4e712d | |||
| 7fae67c47d | |||
| 9ae0ca64af | |||
| da69fa0613 | |||
| 336029b842 | |||
| 29edcd3df9 | |||
| a9ff32418e | |||
| 7358fcb891 | |||
| 28052daad9 | |||
| 364c8c5f7f | |||
| 4b3b418abb | |||
| 2e354d342c | |||
| dd879c8e4a | |||
| af485b9180 | |||
| a739ebaa88 | |||
| 3ebc2578e0 | |||
| 0b657b33f9 | |||
| 7ac942dc0e | |||
| 9b12016428 | |||
| d6b44de4ba | |||
| 17df4839d7 | |||
| e3f78178f7 | |||
| da90ee13c3 | |||
| 583da458ec | |||
| dcf95c4eb2 | |||
| 32cf5790cb | |||
| c272e38de2 | |||
| ca78ddc2c2 | |||
| 07ed8c346a | |||
| d4391f2e2d |
@@ -22,7 +22,7 @@ const tooltipStyle = {
|
||||
color: "#E2E8F0",
|
||||
};
|
||||
|
||||
type DataItem = { date: Date; maxConcurrentRuns: number };
|
||||
type DataItem = { date: string; maxConcurrentRuns: number };
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
@@ -62,25 +62,34 @@ export function ConcurrentRunsChart({
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dataKey={(item: DataItem) => {
|
||||
if (item.date.getDate() === 1) {
|
||||
return dateFormatter.format(item.date);
|
||||
if (!item.date) return "";
|
||||
const date = new Date(item.date);
|
||||
if (date.getDate() === 1) {
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
return `${item.date.getDate()}`;
|
||||
return `${date.getDate()}`;
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
|
||||
</XAxis>
|
||||
<YAxis stroke="#94A3B8" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(value, data) => {
|
||||
const date = data.at(0)?.payload.date;
|
||||
if (!date) {
|
||||
const dateString = data.at(0)?.payload.date;
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
return dateFormatter.format(date);
|
||||
|
||||
return dateFormatter.format(new Date(dateString));
|
||||
}}
|
||||
/>
|
||||
{concurrentRunsLimit && (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Label, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid #1A2434",
|
||||
backgroundColor: "#0B1018",
|
||||
padding: "0.3rem 0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "#E2E8F0",
|
||||
};
|
||||
|
||||
type DataItem = { date: string; runs: number };
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
export function DailyRunsChart({
|
||||
data,
|
||||
hasDailyRunsData,
|
||||
}: {
|
||||
data: DataItem[];
|
||||
hasDailyRunsData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{!hasDailyRunsData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No daily Runs to show
|
||||
</Paragraph>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height="100%" className="relative min-h-[20rem]">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 10,
|
||||
}}
|
||||
className="-ml-8"
|
||||
>
|
||||
<XAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dataKey={(item: DataItem) => {
|
||||
if (!item.date) return "";
|
||||
const date = new Date(item.date);
|
||||
if (date.getDate() === 1) {
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
return `${date.getDate()}`;
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Label value="Last 30 days" offset={-8} position="insideBottom" fill="#94A3B8" />
|
||||
</XAxis>
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
contentStyle={tooltipStyle}
|
||||
labelFormatter={(value, data) => {
|
||||
const dateString = data.at(0)?.payload.date;
|
||||
if (!dateString) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return dateFormatter.format(new Date(dateString));
|
||||
}}
|
||||
/>
|
||||
<Line dataKey="runs" name="Runs" stroke="#16A34A" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export function ConnectToOAuthForm({
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
disabled={requiresCustomOAuthApp}
|
||||
readOnly={requiresCustomOAuthApp}
|
||||
onChange={(checked) => setUseMyOAuthApp(checked)}
|
||||
{...conform.input(hasCustomClient, { type: "checkbox" })}
|
||||
defaultChecked={requiresCustomOAuthApp}
|
||||
@@ -135,8 +135,9 @@ export function ConnectToOAuthForm({
|
||||
{useMyOAuthApp && (
|
||||
<div className="ml-6 mt-2">
|
||||
<Paragraph variant="small" className="mb-2">
|
||||
Set the callback url to <CodeBlock code={callbackUrl} showLineNumbers={false} />
|
||||
Set the callback url to
|
||||
</Paragraph>
|
||||
<CodeBlock code={callbackUrl} showLineNumbers={false} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<InputGroup fullWidth>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function UpdateOAuthForm({
|
||||
id="hasCustomClient"
|
||||
label="Use my OAuth App"
|
||||
variant="simple/small"
|
||||
disabled={requiresCustomOAuthApp}
|
||||
readOnly={requiresCustomOAuthApp}
|
||||
onChange={(checked) => setUseMyOAuthApp(checked)}
|
||||
{...conform.input(hasCustomClient, { type: "checkbox" })}
|
||||
defaultChecked={requiresCustomOAuthApp}
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { loader } from "~/routes/resources.jobs.$jobId";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { JobStatusTable } from "../JobsStatusTable";
|
||||
import { JobEnvironment, JobStatusTable } from "../JobsStatusTable";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Header1, Header2 } from "../primitives/Headers";
|
||||
import { NamedIcon } from "../primitives/NamedIcon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
|
||||
type JobEnvironment = {
|
||||
type: RuntimeEnvironmentType;
|
||||
lastRun?: Date;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
export function DeleteJobDialog({ id, title, slug }: { id: string; title: string; slug: string }) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
useEffect(() => {
|
||||
fetcher.load(`/resources/jobs/${id}`);
|
||||
}, [id]);
|
||||
|
||||
const isLoading = fetcher.state === "loading" || fetcher.state === "submitting";
|
||||
|
||||
if (isLoading || !fetcher.data) {
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-y-6">
|
||||
<div className="mt-5 flex flex-col items-center justify-center gap-y-2">
|
||||
<Header1>{title}</Header1>
|
||||
<Paragraph variant="small">ID: {slug}</Paragraph>
|
||||
</div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DeleteJobDialogContent
|
||||
id={id}
|
||||
title={title}
|
||||
slug={slug}
|
||||
environments={fetcher.data.environments}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteJobDialogContentProps = {
|
||||
id: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "../primitives/Table";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { runStatusTitle } from "../runs/RunStatuses";
|
||||
import { DeleteJobDialogContent } from "./DeleteJobModalContent";
|
||||
import { DeleteJobDialog, DeleteJobDialogContent } from "./DeleteJobModalContent";
|
||||
import { JobStatusBadge } from "./JobStatusBadge";
|
||||
|
||||
export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResultsText: string }) {
|
||||
@@ -49,13 +49,13 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
<TableRow key={job.id} className="group">
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-2">
|
||||
<NamedIcon name={job.event.icon} className="w-8 h-8" />
|
||||
<NamedIcon name={job.event.icon} className="h-8 w-8" />
|
||||
<LabelValueStack
|
||||
label={job.title}
|
||||
value={
|
||||
job.dynamic ? (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<NamedIcon name="dynamic" className="w-4 h-4" />{" "}
|
||||
<NamedIcon name="dynamic" className="h-4 w-4" />{" "}
|
||||
<span className="uppercase">Dynamic:</span> {job.event.title}
|
||||
</span>
|
||||
) : (
|
||||
@@ -75,9 +75,9 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
key={integration.key}
|
||||
button={
|
||||
<div className="relative">
|
||||
<NamedIcon name={integration.icon} className="w-6 h-6" />
|
||||
<NamedIcon name={integration.icon} className="h-6 w-6" />
|
||||
{integration.setupStatus === "MISSING_FIELDS" && (
|
||||
<NamedIcon name="error" className="absolute w-4 h-4 -left-1 -top-1" />
|
||||
<NamedIcon name="error" className="absolute -left-1 -top-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -165,12 +165,7 @@ export function JobsTable({ jobs, noResultsText }: { jobs: ProjectJob[]; noResul
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Delete Job</DialogHeader>
|
||||
<DeleteJobDialogContent
|
||||
id={job.id}
|
||||
title={job.title}
|
||||
slug={job.slug}
|
||||
environments={job.environments}
|
||||
/>
|
||||
<DeleteJobDialog id={job.id} title={job.title} slug={job.slug} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCellMenu>
|
||||
|
||||
@@ -5,6 +5,6 @@ import { cn } from "~/utils/cn";
|
||||
export function PageNavigationIndicator({ className }: { className?: string }) {
|
||||
const navigation = useNavigation();
|
||||
if (navigation.state === "loading") {
|
||||
return <Spinner color="muted" className={cn("h-4 w-4", className)} />;
|
||||
return <Spinner color="blue" className={cn("h-4 w-4", className)} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArrowRightOnRectangleIcon,
|
||||
ChartBarIcon,
|
||||
CursorArrowRaysIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
@@ -15,6 +14,7 @@ import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { MatchedProject } from "~/hooks/useProject";
|
||||
import { User } from "~/models/user.server";
|
||||
import { useV3Enabled } from "~/root";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
organizationBillingPath,
|
||||
organizationIntegrationsPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
projectEnvironmentsPath,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
projectHttpEndpointsPath,
|
||||
projectPath,
|
||||
projectRunsPath,
|
||||
projectSettingsPath,
|
||||
projectSetupPath,
|
||||
projectTriggersPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -56,9 +58,8 @@ import {
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { useV3Enabled } from "~/root";
|
||||
import { MenuCount, SideMenuItem } from "./SideMenuItem";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<
|
||||
@@ -106,11 +107,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
showHeaderDivider ? " border-border" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
project={project}
|
||||
/>
|
||||
<ProjectSelector organizations={organizations} project={project} />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
@@ -118,7 +115,7 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={project.name || "No project found"}>
|
||||
<SideMenuHeader title={"Project"}>
|
||||
<PopoverMenuItem
|
||||
to={projectSetupPath(organization, project)}
|
||||
title="Framework setup"
|
||||
@@ -168,9 +165,16 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
to={projectEnvironmentsPath(organization, project)}
|
||||
data-action="environments & api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={projectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={organization.title}>
|
||||
<SideMenuHeader title={"Organization"}>
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New Project" icon="plus" />
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
@@ -206,10 +210,17 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
iconColor="text-green-600"
|
||||
data-action="usage & billing"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
icon="settings"
|
||||
iconColor="text-teal-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="organization-settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-border p-1">
|
||||
{currentPlan?.subscription?.isPaying === true ? (
|
||||
{currentPlan?.subscription?.isPaying === true && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
@@ -265,16 +276,14 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
)}
|
||||
|
||||
<SideMenuItem
|
||||
name="Join our Discord"
|
||||
icon={DiscordIcon}
|
||||
to="https://trigger.dev/discord"
|
||||
data-action="join our discord"
|
||||
target="_blank"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Documentation"
|
||||
icon="docs"
|
||||
@@ -317,11 +326,9 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organization,
|
||||
organizations,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
}) {
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
@@ -339,7 +346,7 @@ function ProjectSelector({
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{organization.title ?? "Select an organization"}</span>
|
||||
<span className="truncate">{project.name ?? "Select a project"}</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700"
|
||||
@@ -350,23 +357,31 @@ function ProjectSelector({
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{organization.projects.length > 0 ? (
|
||||
organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={projectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
<MenuCount count={p.jobCount} />
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon="folder"
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New project"
|
||||
icon="plus"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -109,14 +109,16 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-start gap-x-2 transition",
|
||||
"group flex items-start gap-x-2 transition ",
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
buttonClassName,
|
||||
isChecked && isCheckedClassName,
|
||||
isDisabled && isDisabledClassName,
|
||||
(isDisabled || props.readOnly) && isDisabledClassName,
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) return;
|
||||
//returning false is not setting the state to false, it stops the event from bubbling up
|
||||
if (isDisabled || props.readOnly === true) return false;
|
||||
setIsChecked((c) => !c);
|
||||
}}
|
||||
>
|
||||
@@ -127,12 +129,15 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
value={value}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
//returning false is not setting the state to false, it stops the event from bubbling up
|
||||
if (isDisabled || props.readOnly === true) return false;
|
||||
setIsChecked(!isChecked);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
inputPositionClasses,
|
||||
"cursor-pointer rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
"rounded-sm border border-slate-700 bg-transparent transition checked:!bg-indigo-500 read-only:border-slate-650 read-only:!bg-slate-700 group-hover:bg-slate-900 group-hover:checked:bg-indigo-500 group-focus:ring-1 focus:ring-indigo-500 focus:ring-offset-0 focus:ring-offset-transparent focus-visible:outline-none focus-visible:ring-indigo-500 disabled:border-slate-650 disabled:!bg-slate-700"
|
||||
)}
|
||||
id={id}
|
||||
ref={ref}
|
||||
@@ -141,7 +146,10 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
<div className="flex items-center gap-x-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn("cursor-pointer", labelClassName)}
|
||||
className={cn(
|
||||
props.readOnly || disabled ? "cursor-default" : "cursor-pointer",
|
||||
labelClassName
|
||||
)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -129,10 +129,10 @@ function PageInfoPropertyContent({
|
||||
{label && (
|
||||
<Paragraph variant="extra-small/caps" className="mt-0.5 whitespace-nowrap">
|
||||
{label}
|
||||
{value && ":"}
|
||||
{value !== undefined && ":"}
|
||||
</Paragraph>
|
||||
)}
|
||||
{value && <Paragraph variant="small">{value}</Paragraph>}
|
||||
{value !== undefined && <Paragraph variant="small">{value}</Paragraph>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ export function RunOverview({ run, trigger, showRerun, paths, currentUser }: Run
|
||||
{showRerun && run.isFinished && (
|
||||
<RerunPopover
|
||||
runId={run.id}
|
||||
runPath={paths.run}
|
||||
runsPath={paths.runsPath}
|
||||
environmentType={run.environment.type}
|
||||
status={run.basicStatus}
|
||||
@@ -317,18 +318,20 @@ function BlankTasks({ status }: { status: RunBasicStatus }) {
|
||||
|
||||
function RerunPopover({
|
||||
runId,
|
||||
runPath,
|
||||
runsPath,
|
||||
environmentType,
|
||||
status,
|
||||
}: {
|
||||
runId: string;
|
||||
runPath: string;
|
||||
runsPath: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
status: RunBasicStatus;
|
||||
}) {
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { successRedirect }] = useForm({
|
||||
const [form, { successRedirect, failureRedirect }] = useForm({
|
||||
id: "rerun",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
@@ -347,6 +350,7 @@ function RerunPopover({
|
||||
<PopoverContent className="flex min-w-[20rem] max-w-[20rem] flex-col gap-2 p-0" align="end">
|
||||
<Form method="post" action={`/resources/runs/${runId}/rerun`} {...form.props}>
|
||||
<input {...conform.input(successRedirect, { type: "hidden" })} defaultValue={runsPath} />
|
||||
<input {...conform.input(failureRedirect, { type: "hidden" })} defaultValue={runPath} />
|
||||
{environmentType === "PRODUCTION" && (
|
||||
<div className="px-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
|
||||
@@ -77,11 +77,11 @@ export function RunsTable({
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
<NoRuns title="No runs found" />
|
||||
{!isLoading && <NoRuns title="No runs found" />}
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<TableBlankRow colSpan={showJob ? 10 : 9}>
|
||||
<NoRuns title="No runs match your filters" />
|
||||
{!isLoading && <NoRuns title="No runs match your filters" />}
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ const mockOrganization: MatchedOrganization = {
|
||||
{ id: "mockId2", slug: "mockSlug2", name: "mockName2", jobCount: 2 },
|
||||
],
|
||||
hasUnconfiguredIntegrations: false,
|
||||
memberCount: 1,
|
||||
runsEnabled: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -110,13 +110,19 @@ function getClient() {
|
||||
// emit: "stdout",
|
||||
// level: "query",
|
||||
// },
|
||||
// {
|
||||
// emit: "event",
|
||||
// level: "query",
|
||||
// },
|
||||
],
|
||||
});
|
||||
|
||||
// client.$on("query", (e) => {
|
||||
// console.log("Query: " + e.query);
|
||||
// console.log("Params: " + e.params);
|
||||
// console.log("Duration: " + e.duration + "ms");
|
||||
// console.log(`Query tooks ${e.duration}ms`, {
|
||||
// query: e.query,
|
||||
// params: e.params,
|
||||
// duration: e.duration,
|
||||
// });
|
||||
// });
|
||||
|
||||
// connect eagerly
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam/route";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
|
||||
export type MatchedProject = UseDataFunctionReturn<typeof loader>["project"];
|
||||
|
||||
export const projectMatchId = "routes/_app.orgs.$organizationSlug.projects.$projectParam";
|
||||
export type MatchedProject = UseDataFunctionReturn<typeof orgLoader>["project"];
|
||||
|
||||
export function useOptionalProject(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: projectMatchId,
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { User } from "~/models/user.server";
|
||||
import { useMatchesData } from "~/utils";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
|
||||
@@ -130,6 +130,9 @@ export async function getUsersInvites({ email }: { email: string }) {
|
||||
return await prisma.orgMemberInvite.findMany({
|
||||
where: {
|
||||
email,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
|
||||
@@ -14,6 +14,11 @@ export async function findEnvironmentByApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -28,24 +33,10 @@ export async function findEnvironmentByPublicApiKey(apiKey: string) {
|
||||
},
|
||||
});
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function getEnvironmentForOrganization(organizationSlug: string, slug: string) {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
include: {
|
||||
environments: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const environment = organization.environments.find((environment) => environment.slug === slug);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export type ClientEndpoint =
|
||||
state: "configured";
|
||||
id: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
url: string | null;
|
||||
indexWebhookPath: string;
|
||||
latestIndex?: {
|
||||
status: EndpointIndexStatus;
|
||||
@@ -102,6 +102,11 @@ export class EnvironmentsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
url: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
|
||||
@@ -40,27 +40,25 @@ export class EventListPresenter {
|
||||
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
});
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Find all runtimeEnvironments that the user has access to
|
||||
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await this.#prismaClient.eventRecord.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
@@ -100,9 +98,6 @@ export class EventListPresenter {
|
||||
},
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentId: {
|
||||
in: environments.map((environment) => environment.id),
|
||||
},
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
createdAt: {
|
||||
gte: from ? new Date(from).toISOString() : undefined,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { z } from "zod";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
import { JobRunStatus } from "@trigger.dev/database";
|
||||
|
||||
export type ProjectJob = Awaited<ReturnType<JobListPresenter["call"]>>[0];
|
||||
|
||||
@@ -43,52 +44,34 @@ export class JobListPresenter {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
integrations: {
|
||||
select: {
|
||||
version: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
status: true,
|
||||
triggerLink: true,
|
||||
triggerHelp: true,
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
take: 1,
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
@@ -115,50 +98,47 @@ export class JobListPresenter {
|
||||
orderBy: [{ title: "asc" }],
|
||||
});
|
||||
|
||||
let latestRuns = [] as {
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[];
|
||||
|
||||
if (jobs.length > 0) {
|
||||
latestRuns = await this.#prismaClient.$queryRaw<
|
||||
{
|
||||
createdAt: Date;
|
||||
status: JobRunStatus;
|
||||
jobId: string;
|
||||
rn: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
"id",
|
||||
"createdAt",
|
||||
"status",
|
||||
"jobId",
|
||||
ROW_NUMBER() OVER(PARTITION BY "jobId" ORDER BY "createdAt" DESC) as rn
|
||||
FROM
|
||||
"JobRun"
|
||||
WHERE
|
||||
"jobId" IN (${Prisma.join(jobs.map((j) => j.id))})
|
||||
) t
|
||||
WHERE rn = 1;`;
|
||||
}
|
||||
|
||||
return jobs
|
||||
.map((job) => {
|
||||
//the best alias to select:
|
||||
// 1. Logged-in user dev
|
||||
// 2. Prod
|
||||
// 3. Any other user's dev
|
||||
const sortedAliases = job.aliases.sort((a, b) => {
|
||||
if (a.environment.type === "DEVELOPMENT" && a.environment.orgMember?.userId === userId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "DEVELOPMENT" && b.environment.orgMember?.userId === userId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.environment.type === "PRODUCTION") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.environment.type === "PRODUCTION") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const alias = sortedAliases.at(0);
|
||||
|
||||
if (!alias) {
|
||||
throw new Error(`No aliases found for job ${job.id}, this should never happen.`);
|
||||
.flatMap((job) => {
|
||||
const version = job.versions.at(0);
|
||||
if (!version) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventSpecification = EventSpecificationSchema.parse(alias.version.eventSpecification);
|
||||
const eventSpecification = EventSpecificationSchema.parse(version.eventSpecification);
|
||||
|
||||
const lastRuns = job.aliases
|
||||
.map((alias) => alias.version.runs.at(0))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
|
||||
const lastRun = lastRuns.at(0);
|
||||
|
||||
const integrations = alias.version.integrations.map((integration) => ({
|
||||
const integrations = job.integrations.map((integration) => ({
|
||||
key: integration.key,
|
||||
title: integration.integration.slug,
|
||||
icon: integration.integration.definition.icon ?? integration.integration.definition.id,
|
||||
@@ -171,44 +151,41 @@ export class JobListPresenter {
|
||||
properties = [...properties, ...eventSpecification.properties];
|
||||
}
|
||||
|
||||
if (alias.version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(alias.version.properties);
|
||||
if (version.properties) {
|
||||
const versionProperties = z.array(DisplayPropertySchema).parse(version.properties);
|
||||
properties = [...properties, ...versionProperties];
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
}));
|
||||
const latestRun = latestRuns.find((r) => r.jobId === job.id);
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: alias.version.version,
|
||||
status: alias.version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
alias.version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
return [
|
||||
{
|
||||
id: job.id,
|
||||
slug: job.slug,
|
||||
title: job.title,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
dynamic: job.dynamicTriggers.length > 0,
|
||||
event: {
|
||||
title: eventSpecification.title,
|
||||
icon: eventSpecification.icon,
|
||||
source: eventSpecification.source,
|
||||
link: projectSlug
|
||||
? `${projectPath({ slug: organizationSlug }, { slug: projectSlug })}/${
|
||||
version.triggerLink
|
||||
}`
|
||||
: undefined,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
environment: version.environment,
|
||||
lastRun: latestRun,
|
||||
properties,
|
||||
projectSlug: job.project.slug,
|
||||
},
|
||||
integrations,
|
||||
hasIntegrationsRequiringAction: integrations.some(
|
||||
(i) => i.setupStatus === "MISSING_FIELDS"
|
||||
),
|
||||
lastRun,
|
||||
properties,
|
||||
environments,
|
||||
projectSlug: job.project.slug,
|
||||
};
|
||||
];
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,16 @@ export class NewOrganizationPresenter {
|
||||
|
||||
public async call({ userId }: { userId: User["id"] }) {
|
||||
const organizations = await this.#prismaClient.organization.findMany({
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
where: { members: { some: { userId } } },
|
||||
});
|
||||
|
||||
return {
|
||||
hasOrganizations: organizations.length > 0,
|
||||
hasOrganizations: organizations.filter((o) => o.projects.length > 0).length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { estimate } from "@trigger.dev/billing";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
export class OrgUsagePresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -25,7 +23,7 @@ export class OrgUsagePresenter {
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return;
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
// Get count of runs since the start of the current month
|
||||
@@ -108,7 +106,7 @@ export class OrgUsagePresenter {
|
||||
|
||||
const ThirtyDaysAgo = new Date();
|
||||
ThirtyDaysAgo.setDate(ThirtyDaysAgo.getDate() - 30);
|
||||
ThirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
ThirtyDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const hasConcurrencyData = concurrencyChartRawData.length > 0;
|
||||
const concurrencyChartRawDataFilledIn = fillInMissingConcurrencyDays(
|
||||
@@ -117,6 +115,13 @@ export class OrgUsagePresenter {
|
||||
concurrencyChartRawData
|
||||
);
|
||||
|
||||
const dailyRunsRawData = await this.#prismaClient.$queryRaw<
|
||||
{ day: Date; runs: BigInt }[]
|
||||
>`SELECT date_trunc('day', "createdAt") as day, COUNT(*) as runs FROM "JobRun" WHERE "organizationId" = ${organization.id} AND "createdAt" >= NOW() - INTERVAL '30 days' AND "internal" = FALSE GROUP BY day`;
|
||||
|
||||
const hasDailyRunsData = dailyRunsRawData.length > 0;
|
||||
const dailyRunsDataFilledIn = fillInMissingDailyRuns(ThirtyDaysAgo, 31, dailyRunsRawData);
|
||||
|
||||
const endOfMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 1);
|
||||
endOfMonth.setDate(endOfMonth.getDate() - 1);
|
||||
const projectedRunsCount = Math.round(
|
||||
@@ -146,12 +151,12 @@ export class OrgUsagePresenter {
|
||||
|
||||
const periodStart = new Date();
|
||||
periodStart.setDate(1);
|
||||
periodStart.setHours(0, 0, 0, 0);
|
||||
periodStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setDate(1);
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
periodEnd.setHours(0, 0, 0, 0);
|
||||
periodEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
return {
|
||||
id: organization.id,
|
||||
@@ -161,6 +166,8 @@ export class OrgUsagePresenter {
|
||||
hasMonthlyRunData,
|
||||
concurrencyData: concurrencyChartRawDataFilledIn,
|
||||
hasConcurrencyData,
|
||||
dailyRunsData: dailyRunsDataFilledIn,
|
||||
hasDailyRunsData,
|
||||
runCostEstimation,
|
||||
projectedRunCostEstimation,
|
||||
periodStart,
|
||||
@@ -224,6 +231,33 @@ function fillInMissingConcurrencyDays(
|
||||
return outputData;
|
||||
}
|
||||
|
||||
function fillInMissingDailyRuns(
|
||||
startDate: Date,
|
||||
days: number,
|
||||
data: Array<{ day: Date; runs: BigInt }>
|
||||
) {
|
||||
const outputData: Array<{ date: Date; runs: number }> = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const foundData = data.find((d) => d.day.toISOString() === date.toISOString());
|
||||
if (!foundData) {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: 0,
|
||||
});
|
||||
} else {
|
||||
outputData.push({
|
||||
date,
|
||||
runs: Number(foundData.runs),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return outputData;
|
||||
}
|
||||
|
||||
// Start month will be like 2023-03 and endMonth will be like 2023-10
|
||||
// The result should be an array of months between these two months, including the start and end month
|
||||
// So for example, if startMonth is 2023-03 and endMonth is 2023-10, the result should be:
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
setCurrentProjectId,
|
||||
} from "~/services/currentProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
type Org = Awaited<ReturnType<OrganizationsPresenter["getOrganizations"]>>[number];
|
||||
import { newProjectPath } from "~/utils/pathBuilder";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { match } from "assert";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -16,40 +23,167 @@ export class OrganizationsPresenter {
|
||||
public async call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
request,
|
||||
projectSlug,
|
||||
request,
|
||||
}: {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string | undefined;
|
||||
request: Request;
|
||||
projectSlug?: string;
|
||||
}) {
|
||||
const organizations = await this.getOrganizations(userId);
|
||||
//first get the project id, this redirects if there's no session
|
||||
const projectId = await this.#getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
|
||||
const organizations = await this.#getOrganizations(userId);
|
||||
const organization = organizations.find((o) => o.slug === organizationSlug);
|
||||
if (!organization) {
|
||||
logger.info("Not Found: organization", {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
request,
|
||||
organization,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const project = await this.getProject(organization, projectSlug, request, userId);
|
||||
const projectPresenter = new ProjectPresenter(this.#prismaClient);
|
||||
const project = await projectPresenter.call({
|
||||
id: projectId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects found in organization"
|
||||
);
|
||||
}
|
||||
|
||||
return { organizations, organization, project };
|
||||
}
|
||||
|
||||
async getOrganizations(userId: string) {
|
||||
async #getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
}: {
|
||||
request: Request;
|
||||
projectSlug: string | undefined;
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
}): Promise<string> {
|
||||
const sessionProjectId = await getCurrentProjectId(request);
|
||||
|
||||
//no project in session, let's set one
|
||||
if (!sessionProjectId) {
|
||||
//no session id and no project slug so we need to select the best project
|
||||
if (!projectSlug) {
|
||||
const bestProject = await this.#selectBestProjectForOrganization(
|
||||
organizationSlug,
|
||||
userId,
|
||||
request
|
||||
);
|
||||
const session = await setCurrentProjectId(bestProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
//get all the projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
slug: projectSlug,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects in this organization"
|
||||
);
|
||||
}
|
||||
|
||||
//try get the project which matches the URL
|
||||
let matchingProject = projects.find((p) => p.slug === projectSlug);
|
||||
|
||||
//if there's no matching project, just use the most recently updated one
|
||||
if (!matchingProject) {
|
||||
matchingProject = projects[0];
|
||||
}
|
||||
|
||||
//set the session
|
||||
const session = await setCurrentProjectId(matchingProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
if (!projectSlug) {
|
||||
return sessionProjectId;
|
||||
}
|
||||
|
||||
//check session id matches the project slug
|
||||
const project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found in organization", { status: 404 });
|
||||
}
|
||||
|
||||
if (project.id !== sessionProjectId) {
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async #getOrganizations(userId: string) {
|
||||
const orgs = await this.#prismaClient.organization.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
where: { members: { some: { userId } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
runsEnabled: true,
|
||||
projects: {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
where: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
_count: {
|
||||
select: {
|
||||
jobs: {
|
||||
@@ -67,10 +201,10 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
@@ -93,39 +227,41 @@ export class OrganizationsPresenter {
|
||||
jobCount: project._count.jobs,
|
||||
})),
|
||||
hasUnconfiguredIntegrations: org._count.integrations > 0,
|
||||
memberCount: org._count.members,
|
||||
runsEnabled: org.runsEnabled,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getProject(
|
||||
organization: Org,
|
||||
projectSlug: string | undefined,
|
||||
request: Request,
|
||||
userId: string
|
||||
async #selectBestProjectForOrganization(
|
||||
organizationSlug: string,
|
||||
userId: string,
|
||||
request: Request
|
||||
) {
|
||||
const projectPresenter = new ProjectPresenter();
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
jobs: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!projectSlug) {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
const orgProject = organization.projects.find((p) => p.id === projectId);
|
||||
if (!orgProject) {
|
||||
logger.info("Not Found: proj 1", {
|
||||
projectId,
|
||||
organization,
|
||||
projectSlug: projectSlug ?? null,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
projectSlug = orgProject.slug;
|
||||
if (projects.length === 0) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
|
||||
const project = await projectPresenter.call({ userId, slug: projectSlug });
|
||||
if (!project) {
|
||||
logger.info("Not Found: proj 2", { projectSlug, organization, project });
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
return project;
|
||||
return projects[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export class ProjectPresenter {
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
slug,
|
||||
}: Pick<Project, "slug"> & {
|
||||
id,
|
||||
}: Pick<Project, "id"> & {
|
||||
userId: User["id"];
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
@@ -23,67 +23,7 @@ export class ProjectPresenter {
|
||||
organizationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
jobs: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
eventSpecification: true,
|
||||
properties: true,
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
integrations: {
|
||||
select: {
|
||||
key: true,
|
||||
integration: {
|
||||
select: {
|
||||
slug: true,
|
||||
definition: true,
|
||||
setupStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
},
|
||||
dynamicTriggers: {
|
||||
select: {
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
internal: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: [{ title: "asc" }],
|
||||
},
|
||||
deletedAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
sources: {
|
||||
@@ -100,19 +40,6 @@ export class ProjectPresenter {
|
||||
httpEndpoints: true,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
integrations: {
|
||||
where: {
|
||||
setupStatus: "MISSING_FIELDS",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -127,7 +54,7 @@ export class ProjectPresenter {
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { slug, organization: { members: { some: { userId } } } },
|
||||
where: { id, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
@@ -141,6 +68,7 @@ export class ProjectPresenter {
|
||||
organizationId: project.organizationId,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt,
|
||||
deletedAt: project.deletedAt,
|
||||
hasInactiveExternalTriggers: project._count.sources > 0,
|
||||
jobCount: project._count.jobs,
|
||||
httpEndpointCount: project._count.httpEndpoints,
|
||||
|
||||
@@ -54,6 +54,9 @@ export class RunListPresenter {
|
||||
|
||||
// Find the organization that the user is a member of
|
||||
const organization = await this.#prismaClient.organization.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
@@ -62,19 +65,15 @@ export class RunListPresenter {
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Find all runtimeEnvironments that the user has access to
|
||||
const environments = await this.#prismaClient.runtimeEnvironment.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const job = jobSlug
|
||||
? await this.#prismaClient.job.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -132,9 +131,6 @@ export class RunListPresenter {
|
||||
jobId: job?.id,
|
||||
projectId: project.id,
|
||||
organizationId: organization.id,
|
||||
environmentId: {
|
||||
in: environments.map((environment) => environment.id),
|
||||
},
|
||||
status: filterStatuses ? { in: filterStatuses } : undefined,
|
||||
environment: filterEnvironment ? { type: filterEnvironment } : undefined,
|
||||
startedAt: {
|
||||
|
||||
@@ -14,7 +14,7 @@ export class SelectBestProjectPresenter {
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
if (projectId) {
|
||||
const project = await this.#prismaClient.project.findUnique({
|
||||
where: { id: projectId, organization: { members: { some: { userId } } } },
|
||||
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
include: { organization: true },
|
||||
});
|
||||
if (project) {
|
||||
@@ -28,6 +28,7 @@ export class SelectBestProjectPresenter {
|
||||
organization: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return options.defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
export function ErrorBoundary() {
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Await, useLoaderData } from "@remix-run/react";
|
||||
import { DataFunctionArgs, defer } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ConcurrentRunsChart } from "~/components/billing/ConcurrentRunsChart";
|
||||
import { UsageBar } from "~/components/billing/UsageBar";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DailyRunsChart } from "~/components/billing/DailyRunsChat";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { OrgUsagePresenter } from "~/presenters/OrgUsagePresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency, formatNumberCompact } from "~/utils/numberFormatter";
|
||||
import { OrganizationParamsSchema, plansPath } from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
export async function loader({ request, params }: DataFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const presenter = new OrgUsagePresenter();
|
||||
|
||||
const data = await presenter.call({ userId, slug: organizationSlug, request });
|
||||
|
||||
if (!data) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson(data);
|
||||
const usageData = presenter.call({ userId, slug: organizationSlug, request });
|
||||
return defer({ usageData });
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
||||
@@ -47,146 +44,194 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const loaderData = useTypedLoaderData<typeof loader>();
|
||||
const { usageData } = useLoaderData<typeof loader>();
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
|
||||
? loaderData.concurrencyData.some(
|
||||
(c) => c.maxConcurrentRuns >= (currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
|
||||
)
|
||||
: false;
|
||||
|
||||
const hitsRunLimit = currentPlan?.usage?.runCountCap
|
||||
? currentPlan.usage.currentRunCount > currentPlan.usage.runCountCap
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Header2 spacing>Concurrent runs</Header2>
|
||||
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
|
||||
{hitConcurrencyLimit && (
|
||||
<Callout
|
||||
variant={"pricing"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Increase concurrent runs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
{`Some of your runs are being queued because the number of concurrent runs is limited to
|
||||
${currentPlan?.subscription?.limits.concurrentRuns}.`}
|
||||
</Callout>
|
||||
)}
|
||||
<ConcurrentRunsChart
|
||||
data={loaderData.concurrencyData}
|
||||
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
|
||||
hasConcurrencyData={loaderData.hasConcurrencyData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<>
|
||||
<LoadingElement title="Concurrent runs" />
|
||||
<LoadingElement title="Runs" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={usageData}
|
||||
errorElement={<Paragraph>There was a problem loading your usage data.</Paragraph>}
|
||||
>
|
||||
{(data) => {
|
||||
const hitConcurrencyLimit = currentPlan?.subscription?.limits.concurrentRuns
|
||||
? data.concurrencyData.some(
|
||||
(c) =>
|
||||
c.maxConcurrentRuns >=
|
||||
(currentPlan.subscription?.limits.concurrentRuns ?? Infinity)
|
||||
)
|
||||
: false;
|
||||
|
||||
<div className="@container">
|
||||
<Header2 spacing>Runs</Header2>
|
||||
<div className="flex flex-col gap-5 rounded border border-border p-6">
|
||||
{hitsRunLimit && (
|
||||
<Callout
|
||||
variant={"error"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small" className="text-white">
|
||||
You have exceeded the monthly{" "}
|
||||
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs limit.
|
||||
Upgrade to a paid plan before{" "}
|
||||
<DateTime date={loaderData.periodEnd} includeSeconds={false} includeTime={false} />.
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
)}
|
||||
<div className="flex flex-col gap-x-8 @4xl:flex-row">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{loaderData.runCostEstimation !== undefined &&
|
||||
loaderData.projectedRunCostEstimation !== undefined && (
|
||||
<div className="flex w-full items-center gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header3 className="">Month-to-date</Header3>
|
||||
<p className="text-3xl font-medium text-bright">
|
||||
{formatCurrency(loaderData.runCostEstimation, false)}
|
||||
</p>
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Header2 spacing>Concurrent runs</Header2>
|
||||
<div className="flex w-full flex-col gap-5 rounded border border-border p-6">
|
||||
{hitConcurrencyLimit && (
|
||||
<Callout
|
||||
variant={"pricing"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Increase concurrent runs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
{`Some of your runs are being queued because the number of concurrent runs is limited to
|
||||
${currentPlan?.subscription?.limits.concurrentRuns}.`}
|
||||
</Callout>
|
||||
)}
|
||||
<ConcurrentRunsChart
|
||||
data={data.concurrencyData}
|
||||
concurrentRunsLimit={currentPlan?.subscription?.limits.concurrentRuns}
|
||||
hasConcurrencyData={data.hasConcurrencyData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container">
|
||||
<Header2 spacing>Runs</Header2>
|
||||
<div className="flex flex-col gap-5 rounded border border-border p-6">
|
||||
{hitsRunLimit && (
|
||||
<Callout
|
||||
variant={"error"}
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="px-0"
|
||||
to={plansPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph variant="small" className="text-white">
|
||||
You have exceeded the monthly{" "}
|
||||
{formatNumberCompact(currentPlan?.subscription?.limits.runs ?? 0)} runs
|
||||
limit. Upgrade to a paid plan before{" "}
|
||||
<DateTime
|
||||
date={data.periodEnd}
|
||||
includeSeconds={false}
|
||||
includeTime={false}
|
||||
/>
|
||||
.
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
)}
|
||||
<div className="flex flex-col gap-x-8 @4xl:flex-row">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{data.runCostEstimation !== undefined &&
|
||||
data.projectedRunCostEstimation !== undefined && (
|
||||
<div className="flex w-full items-center gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header3 className="">Month-to-date</Header3>
|
||||
<p className="text-3xl font-medium text-bright">
|
||||
{formatCurrency(data.runCostEstimation, false)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
|
||||
<div className="flex flex-col gap-2 text-dimmed">
|
||||
<Header3 className="text-dimmed">Projected</Header3>
|
||||
<p className="text-3xl font-medium">
|
||||
{formatCurrency(data.projectedRunCostEstimation, false)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={data.runsCount}
|
||||
tierRunLimit={
|
||||
currentPlan?.usage.runCountCap ??
|
||||
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
|
||||
}
|
||||
projectedRuns={data.projectedRunsCount}
|
||||
subscribedToPaidTier={
|
||||
(currentPlan && currentPlan.subscription?.isPaying) ?? false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<Header3 className="mb-4">Monthly runs</Header3>
|
||||
{!data.hasMonthlyRunData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No runs to show
|
||||
</Paragraph>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart
|
||||
data={data.monthlyRunsData}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
className="-ml-7"
|
||||
>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
content={<CustomTooltip />}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-6 w-6 text-dimmed/50" />
|
||||
<div className="flex flex-col gap-2 text-dimmed">
|
||||
<Header3 className="text-dimmed">Projected</Header3>
|
||||
<p className="text-3xl font-medium">
|
||||
{formatCurrency(loaderData.projectedRunCostEstimation, false)}
|
||||
</p>
|
||||
<div>
|
||||
<Header3 className="mb-4">Daily runs</Header3>
|
||||
<DailyRunsChart
|
||||
data={data.dailyRunsData}
|
||||
hasDailyRunsData={data.hasDailyRunsData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<UsageBar
|
||||
numberOfCurrentRuns={loaderData.runsCount}
|
||||
tierRunLimit={
|
||||
currentPlan?.usage.runCountCap ??
|
||||
currentPlan?.subscription?.plan.runs?.pricing?.brackets.at(0)?.upto
|
||||
}
|
||||
projectedRuns={loaderData.projectedRunsCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-full">
|
||||
<Header3 className="mb-4">Monthly runs</Header3>
|
||||
{!loaderData.hasMonthlyRunData && (
|
||||
<Paragraph className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
No runs to show
|
||||
</Paragraph>
|
||||
)}
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart
|
||||
data={loaderData.monthlyRunsData}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
className="-ml-7"
|
||||
>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94A3B8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(255,255,255,0.05)" }}
|
||||
content={<CustomTooltip />}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#16A34A" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingElement({ title }: { title: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Header2 spacing>{title}</Header2>
|
||||
<div className="flex h-96 w-full items-center justify-center gap-5 rounded border border-border p-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,31 +38,23 @@ import {
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { MatchedOrganization, useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import { Project } from "~/models/project.server";
|
||||
import {
|
||||
Client,
|
||||
IntegrationOrApi,
|
||||
IntegrationsPresenter,
|
||||
} from "~/presenters/IntegrationsPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
ProjectParamSchema,
|
||||
docsCreateIntegration,
|
||||
docsPath,
|
||||
integrationClientPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { OrganizationParamsSchema, docsPath, integrationClientPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const presenter = new IntegrationsPresenter();
|
||||
const data = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -18,7 +18,7 @@ import {
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { IntegrationClientPresenter } from "~/presenters/IntegrationClientPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
IntegrationClientParamSchema,
|
||||
@@ -29,12 +29,12 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, clientParam } = IntegrationClientParamSchema.parse(params);
|
||||
|
||||
const presenter = new IntegrationClientPresenter();
|
||||
const client = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
clientSlug: clientParam,
|
||||
});
|
||||
|
||||
+29
-7
@@ -47,6 +47,9 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
|
||||
const refreshEndpointFetcher = useFetcher();
|
||||
const refreshingEndpoint = refreshEndpointFetcher.state !== "idle";
|
||||
|
||||
const deleteEndpointFetcher = useFetcher();
|
||||
const deletingEndpoint = deleteEndpointFetcher.state !== "idle";
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const events = useEventSource(endpointStreamingPath({ id: endpoint.environment.id }), {
|
||||
event: "message",
|
||||
@@ -70,12 +73,30 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
|
||||
>
|
||||
<SheetContent size="lg">
|
||||
<SheetHeader>
|
||||
<Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
|
||||
<Header1>Configure endpoint</Header1>
|
||||
</div>
|
||||
</Header1>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentLabel environment={{ type: endpoint.environment.type }} />
|
||||
<Header1>Configure endpoint</Header1>
|
||||
</div>
|
||||
</Header1>
|
||||
{endpoint.state === "configured" && (
|
||||
<deleteEndpointFetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Button
|
||||
variant="danger/small"
|
||||
type="submit"
|
||||
disabled={deletingEndpoint}
|
||||
LeadingIcon={deletingEndpoint ? "spinner-white" : undefined}
|
||||
>
|
||||
{deletingEndpoint ? "Deleting" : "Delete"}
|
||||
</Button>
|
||||
</deleteEndpointFetcher.Form>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<setEndpointUrlFetcher.Form
|
||||
@@ -90,7 +111,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
|
||||
<Input
|
||||
className="rounded-r-none"
|
||||
{...conform.input(url, { type: "url" })}
|
||||
defaultValue={"url" in endpoint ? endpoint.url : ""}
|
||||
defaultValue={"url" in endpoint ? endpoint.url ?? "" : ""}
|
||||
placeholder="URL for your Trigger API route"
|
||||
/>
|
||||
<Button
|
||||
@@ -123,6 +144,7 @@ export function ConfigureEndpointSheet({ slug, endpoint, onClose }: ConfigureEnd
|
||||
method="post"
|
||||
action={`/resources/environments/${endpoint.environment.id}/endpoint/${endpoint.id}`}
|
||||
>
|
||||
<input type="hidden" name="action" value="refresh" />
|
||||
<Callout
|
||||
variant="info"
|
||||
icon={
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
type List = {
|
||||
pagination: {
|
||||
next: string | undefined;
|
||||
previous: string | undefined;
|
||||
next?: string | undefined;
|
||||
previous?: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+2
-8
@@ -1,12 +1,9 @@
|
||||
import { Outlet, useLocation } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Fragment } from "react";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { JobStatusBadge } from "~/components/jobs/JobStatusBadge";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { BreadcrumbIcon } from "~/components/primitives/BreadcrumbIcon";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { NamedIcon } from "~/components/primitives/NamedIcon";
|
||||
import {
|
||||
@@ -22,11 +19,9 @@ import {
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useJob } from "~/hooks/useJob";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { projectMatchId, useProject } from "~/hooks/useProject";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useOptionalRun } from "~/hooks/useRun";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { findJobByParams } from "~/models/job.server";
|
||||
import { JobListPresenter } from "~/presenters/JobListPresenter.server";
|
||||
import { JobPresenter } from "~/presenters/JobPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { titleCase } from "~/utils";
|
||||
@@ -36,7 +31,6 @@ import {
|
||||
jobPath,
|
||||
jobSettingsPath,
|
||||
jobTestPath,
|
||||
jobTriggerPath,
|
||||
trimTrailingSlash,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
|
||||
+49
-16
@@ -1,5 +1,5 @@
|
||||
import { useLocation, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Await, useLoaderData, useLocation, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -20,6 +20,8 @@ import { ProjectParamSchema, docsPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { RunsFilters } from "~/components/runs/RunFilters";
|
||||
import { Suspense } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -31,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
|
||||
const list = await presenter.call({
|
||||
const list = presenter.call({
|
||||
userId,
|
||||
filterEnvironment: searchParams.environment,
|
||||
filterStatus: searchParams.status,
|
||||
@@ -44,13 +46,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
to: searchParams.to,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
return defer({
|
||||
list,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { list } = useTypedLoaderData<typeof loader>();
|
||||
const { list } = useLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
@@ -79,18 +81,49 @@ export default function Page() {
|
||||
<div className="h-full overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-slate-700">
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<RunsFilters />
|
||||
<ListPagination list={list} />
|
||||
<Suspense fallback={<></>}>
|
||||
<Await resolve={list}>{(data) => <ListPagination list={data} />}</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
<RunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={false}
|
||||
showJob={true}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
runsParentPath={projectPath(organization, project)}
|
||||
currentUser={user}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
<Suspense
|
||||
fallback={
|
||||
<RunsTable
|
||||
total={0}
|
||||
hasFilters={false}
|
||||
showJob={true}
|
||||
runs={[]}
|
||||
isLoading={true}
|
||||
runsParentPath={projectPath(organization, project)}
|
||||
currentUser={user}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Await resolve={list}>
|
||||
{(data) => {
|
||||
const runs = data.runs.map((run) => ({
|
||||
...run,
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : null,
|
||||
completedAt: run.completedAt ? new Date(run.completedAt) : null,
|
||||
createdAt: new Date(run.createdAt),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RunsTable
|
||||
total={data.runs.length}
|
||||
hasFilters={false}
|
||||
showJob={true}
|
||||
runs={runs}
|
||||
isLoading={isLoading}
|
||||
runsParentPath={projectPath(organization, project)}
|
||||
currentUser={user}
|
||||
/>
|
||||
<ListPagination list={data} className="mt-2 justify-end" />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
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 { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
} from "~/services/currentProject.server";
|
||||
import { DeleteProjectService } from "~/services/deleteProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
projectSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${projectSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = params;
|
||||
if (!organizationSlug || !projectParam) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === projectParam, projectSlug: projectParam };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
await prisma.project.update({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
name: submission.value.projectName,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
projectPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Project renamed to ${submission.value.projectName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const deleteProjectService = new DeleteProjectService();
|
||||
try {
|
||||
await deleteProjectService.call({ projectSlug: projectParam, userId });
|
||||
|
||||
//we need to clear the project from the session
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(organizationPath({ slug: organizationSlug }), {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession) },
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.error("Project could not be deleted", {
|
||||
error: error instanceof Error ? error.message : JSON.stringify(error),
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Project ${projectParam} could not be deleted`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const project = useProject();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [renameForm, { projectName }] = useForm({
|
||||
id: "rename-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteForm, { projectSlug }] = useForm({
|
||||
id: "delete-project",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${project.name} project settings`} />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Form method="post" {...renameForm.props} className="max-w-md">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Rename your project</Label>
|
||||
<Input
|
||||
{...conform.input(projectName, { type: "text" })}
|
||||
defaultValue={project.name}
|
||||
placeholder="Your project name"
|
||||
icon="folder"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={projectName.errorId}>{projectName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
disabled={isRenameLoading}
|
||||
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
Rename project
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<Form
|
||||
method="post"
|
||||
{...deleteForm.props}
|
||||
className="max-w-md rounded-sm border border-rose-500/40"
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Fieldset className="p-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectSlug.id}>Delete project</Label>
|
||||
<Input
|
||||
{...conform.input(projectSlug, { type: "text" })}
|
||||
placeholder="Your project slug"
|
||||
icon="warning"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Delete project
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -19,17 +19,17 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { TriggersPresenter } from "~/presenters/TriggersPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, externalTriggerPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const presenter = new TriggersPresenter();
|
||||
const data = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
+3
-3
@@ -21,17 +21,17 @@ import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { ScheduledTriggersPresenter } from "~/presenters/ScheduledTriggersPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { ProjectParamSchema, docsPath, trimTrailingSlash } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const presenter = new ScheduledTriggersPresenter();
|
||||
const data = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
+3
-3
@@ -31,7 +31,7 @@ import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { TriggerSourcePresenter } from "~/presenters/TriggerSourcePresenter.server";
|
||||
import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ActivateSourceService } from "~/services/sources/activateSource.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -54,7 +54,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new TriggerSourcePresenter();
|
||||
const { trigger } = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
triggerSourceId: triggerParam,
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -38,7 +38,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new WebhookSourcePresenter();
|
||||
const { trigger } = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { WebhookDeliveryPresenter } from "~/presenters/WebhookDeliveryPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { ListPagination } from "../_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam._index/ListPagination";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new WebhookDeliveryPresenter();
|
||||
const { webhook } = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ import { RunListSearchSchema } from "~/components/runs/RunStatuses";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { WebhookSourcePresenter } from "~/presenters/WebhookSourcePresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
TriggerSourceParamSchema,
|
||||
projectWebhookTriggersPath,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, triggerParam } = TriggerSourceParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -33,7 +33,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
const presenter = new WebhookSourcePresenter();
|
||||
const { trigger } = await presenter.call({
|
||||
userId: user.id,
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
webhookId: triggerParam,
|
||||
|
||||
+5
-55
@@ -1,67 +1,17 @@
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
|
||||
import { BreadcrumbLink } from "~/components/navigation/Breadcrumb";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { organizationMatchId, useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData } from "~/hooks/useTypedMatchData";
|
||||
import { ProjectPresenter } from "~/presenters/ProjectPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
import { Handle } from "~/utils/handle";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam } = params;
|
||||
invariant(projectParam, "projectParam not found");
|
||||
|
||||
try {
|
||||
const presenter = new ProjectPresenter();
|
||||
|
||||
const project = await presenter.call({
|
||||
userId,
|
||||
slug: projectParam,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Not Found", {
|
||||
status: 404,
|
||||
statusText: `Project ${projectParam} not found in your Organization.`,
|
||||
});
|
||||
}
|
||||
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
project,
|
||||
},
|
||||
{
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
});
|
||||
}
|
||||
};
|
||||
import { loader as orgLoader } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export const handle: Handle = {
|
||||
breadcrumb: (match) => {
|
||||
const data = useTypedMatchData<typeof loader>(match);
|
||||
breadcrumb: (match, matches) => {
|
||||
const orgMatch = matches.find((m) => m.id === organizationMatchId);
|
||||
const data = useTypedMatchData<typeof orgLoader>(orgMatch);
|
||||
return <BreadcrumbLink to={match.pathname} title={data?.project.name ?? "Project"} />;
|
||||
},
|
||||
scripts: (match) => [
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { r } from "tar";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
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 { PageHeader, PageTitle, PageTitleRow } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
clearCurrentProjectId,
|
||||
commitCurrentProjectSession,
|
||||
} from "~/services/currentProject.server";
|
||||
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
|
||||
|
||||
export function createSchema(
|
||||
constraints: {
|
||||
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
|
||||
} = {}
|
||||
) {
|
||||
return z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("rename"),
|
||||
organizationName: z
|
||||
.string()
|
||||
.min(3, "Organization name must have at least 3 characters")
|
||||
.max(50),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("delete"),
|
||||
organizationSlug: z.string().superRefine((slug, ctx) => {
|
||||
if (constraints.getSlugMatch === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: conform.VALIDATION_UNDEFINED,
|
||||
});
|
||||
} else {
|
||||
const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
|
||||
if (isMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `The slug must match ${organizationSlug}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = params;
|
||||
if (!organizationSlug) {
|
||||
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const schema = createSchema({
|
||||
getSlugMatch: (slug) => {
|
||||
return { isMatch: slug === organizationSlug, organizationSlug };
|
||||
},
|
||||
});
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (submission.value.action) {
|
||||
case "rename": {
|
||||
await prisma.organization.update({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: submission.value.organizationName,
|
||||
},
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
organizationPath({ slug: organizationSlug }),
|
||||
request,
|
||||
`Organization renamed to ${submission.value.organizationName}`
|
||||
);
|
||||
}
|
||||
case "delete": {
|
||||
const deleteOrganizationService = new DeleteOrganizationService();
|
||||
try {
|
||||
await deleteOrganizationService.call({ organizationSlug, userId, request });
|
||||
|
||||
//we need to clear the project from the session
|
||||
const removeProjectIdSession = await clearCurrentProjectId(request);
|
||||
return redirect(rootPath(), {
|
||||
headers: {
|
||||
"Set-Cookie": await commitCurrentProjectSession(removeProjectIdSession),
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||
logger.error("Organization could not be deleted", {
|
||||
error: errorMessage,
|
||||
});
|
||||
return redirectWithErrorMessage(
|
||||
organizationSettingsPath({ slug: organizationSlug }),
|
||||
request,
|
||||
errorMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [renameForm, { organizationName }] = useForm({
|
||||
id: "rename-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteForm, { organizationSlug }] = useForm({
|
||||
id: "delete-organization",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
shouldValidate: "onInput",
|
||||
shouldRevalidate: "onSubmit",
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, {
|
||||
schema: createSchema({
|
||||
getSlugMatch: (slug) => ({
|
||||
isMatch: slug === organization.slug,
|
||||
organizationSlug: organization.slug,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isRenameLoading =
|
||||
navigation.formData?.get("action") === "rename" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
const isDeleteLoading =
|
||||
navigation.formData?.get("action") === "delete" &&
|
||||
(navigation.state === "submitting" || navigation.state === "loading");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitleRow>
|
||||
<PageTitle title={`${organization.title} organization settings`} />
|
||||
</PageTitleRow>
|
||||
</PageHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Form method="post" {...renameForm.props} className="max-w-md">
|
||||
<input type="hidden" name="action" value="rename" />
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={organizationName.id}>Rename your organization</Label>
|
||||
<Input
|
||||
{...conform.input(organizationName, { type: "text" })}
|
||||
defaultValue={organization.title}
|
||||
placeholder="Your organization name"
|
||||
icon="folder"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={organizationName.errorId}>{organizationName.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
disabled={isRenameLoading}
|
||||
LeadingIcon={isRenameLoading ? "spinner-white" : undefined}
|
||||
>
|
||||
Rename organization
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Header2 spacing>Danger zone</Header2>
|
||||
<Form
|
||||
method="post"
|
||||
{...deleteForm.props}
|
||||
className="max-w-md rounded-sm border border-rose-500/40"
|
||||
>
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<Fieldset className="p-4">
|
||||
<InputGroup>
|
||||
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
|
||||
<Input
|
||||
{...conform.input(organizationSlug, { type: "text" })}
|
||||
placeholder="Your organization slug"
|
||||
icon="warning"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={organizationSlug.errorId}>{organizationSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Organization slug
|
||||
<InlineCode variant="extra-small">{organization.slug}</InlineCode> and then
|
||||
press Delete.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"danger/small"}
|
||||
LeadingIcon={isDeleteLoading ? "spinner-white" : "trash-can"}
|
||||
leadingIconClassName="text-white"
|
||||
disabled={isDeleteLoading}
|
||||
>
|
||||
Delete organization
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, UIMatch } from "@remix-run/react";
|
||||
import { Outlet, ShouldRevalidateFunction, UIMatch } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
@@ -9,11 +9,10 @@ import { PageNavigationIndicator } from "~/components/navigation/PageNavigationI
|
||||
import { SideMenu } from "~/components/navigation/SideMenu";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
import { useTypedMatchData, useTypedMatchesData } from "~/hooks/useTypedMatchData";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { telemetry } from "~/services/telemetry.server";
|
||||
@@ -48,6 +47,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
|
||||
telemetry.organization.identify({ organization });
|
||||
telemetry.project.identify({ project });
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
@@ -56,7 +56,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return typedjson({
|
||||
organizations,
|
||||
organization,
|
||||
currentProject: project,
|
||||
project,
|
||||
isImpersonating: !!impersonationId,
|
||||
currentPlan,
|
||||
});
|
||||
@@ -72,13 +72,10 @@ export const handle: Handle = {
|
||||
};
|
||||
|
||||
export default function Organization() {
|
||||
const { organization, currentProject, organizations, isImpersonating } =
|
||||
const { organization, project, organizations, isImpersonating } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
|
||||
//the side menu won't change projects when using the switcher unless we use the hook (on project pages)
|
||||
const project = useOptionalProject() ?? currentProject;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
@@ -111,3 +108,23 @@ export function ErrorBoundary() {
|
||||
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
|
||||
);
|
||||
}
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
defaultShouldRevalidate,
|
||||
currentParams,
|
||||
nextParams,
|
||||
}) => {
|
||||
const current = ParamsSchema.safeParse(currentParams);
|
||||
const next = ParamsSchema.safeParse(nextParams);
|
||||
|
||||
if (current.success && next.success) {
|
||||
if (current.data.organizationSlug !== next.data.organizationSlug) {
|
||||
return true;
|
||||
}
|
||||
if (current.data.projectParam !== next.data.projectParam) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
+51
-9
@@ -1,12 +1,14 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import type { ActionFunction } from "@remix-run/node";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
@@ -14,11 +16,44 @@ import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createProject } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
import { OrganizationParamsSchema, organizationPath, projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: { slug: organizationSlug, members: { some: { userId } } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
_count: {
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Response(null, { status: 404, statusText: "Organization not found" });
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
title: organization.title,
|
||||
slug: organizationSlug,
|
||||
projectsCount: organization._count.projects,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
|
||||
@@ -54,7 +89,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function NewOrganizationPage() {
|
||||
const organization = useOrganization();
|
||||
const { organization } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
|
||||
const [form, { projectName }] = useForm({
|
||||
@@ -71,10 +106,15 @@ export default function NewOrganizationPage() {
|
||||
<div>
|
||||
<FormTitle
|
||||
LeadingIcon="folder"
|
||||
title="Create a new Project"
|
||||
description="Create a new Project to help you organize the Jobs you create."
|
||||
title="Create a new project"
|
||||
description={`This will create a new project in your "${organization.title}" organization. `}
|
||||
/>
|
||||
<Form method="post" {...form.props}>
|
||||
{organization.projectsCount === 0 && (
|
||||
<Callout variant="info" className="mb-4">
|
||||
Organizations require at least one project, please create one to continue.
|
||||
</Callout>
|
||||
)}
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectName.id}>Project name</Label>
|
||||
@@ -93,9 +133,11 @@ export default function NewOrganizationPage() {
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
organization.projectsCount > 0 ? (
|
||||
<LinkButton to={organizationPath(organization)} variant={"secondary/small"}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
@@ -41,10 +41,11 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
const orgsPresenter = new OrganizationsPresenter();
|
||||
const { organizations, organization, project } = await orgsPresenter.call({
|
||||
const { project } = await orgsPresenter.call({
|
||||
userId,
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug: undefined,
|
||||
});
|
||||
|
||||
return typedjson({ plans: result.plans, organizationSlug, projectSlug: project.slug });
|
||||
@@ -52,7 +53,6 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { plans, organizationSlug, projectSlug } = useTypedLoaderData<typeof loader>();
|
||||
const project = useOptionalProject();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-[80rem] flex-col items-center justify-center gap-12 overflow-y-auto px-12">
|
||||
@@ -63,7 +63,6 @@ export default function ChoosePlanPage() {
|
||||
showActionText={false}
|
||||
freeButtonPath={projectPath({ slug: organizationSlug }, { slug: projectSlug })}
|
||||
/>
|
||||
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="tertiary/small" LeadingIcon={ChartBarIcon} leadingIconClassName="px-0">
|
||||
|
||||
@@ -26,6 +26,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
userId,
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug: undefined,
|
||||
});
|
||||
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parse } from "@conform-to/zod";
|
||||
import { RadioGroup } from "@radix-ui/react-radio-group";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
@@ -23,7 +23,7 @@ import { createOrganization } from "~/models/organization.server";
|
||||
import { NewOrganizationPresenter } from "~/presenters/NewOrganizationPresenter.server";
|
||||
import { commitCurrentProjectSession, setCurrentProjectId } from "~/services/currentProject.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { plansPath, projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
|
||||
import { projectPath, rootPath, selectPlanPath } from "~/utils/pathBuilder";
|
||||
|
||||
const schema = z.object({
|
||||
orgName: z.string().min(3).max(50),
|
||||
@@ -86,6 +86,7 @@ export default function NewOrganizationPage() {
|
||||
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [form, { orgName, projectName }] = useForm({
|
||||
id: "create-organization",
|
||||
@@ -95,8 +96,11 @@ export default function NewOrganizationPage() {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
shouldValidate: "onSubmit",
|
||||
});
|
||||
|
||||
const isLoading = navigation.state === "submitting" || navigation.state === "loading";
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
<FormTitle LeadingIcon="organization" title="Create an Organization" />
|
||||
@@ -161,7 +165,12 @@ export default function NewOrganizationPage() {
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} TrailingIcon="arrow-right">
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/small"}
|
||||
TrailingIcon="arrow-right"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function Page() {
|
||||
<Checkbox
|
||||
id="marketingEmails"
|
||||
{...conform.input(marketingEmails, { type: "checkbox" })}
|
||||
label="Receive product updates"
|
||||
label="Receive onboarding emails"
|
||||
variant="simple/small"
|
||||
defaultChecked={user.marketingEmails}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import { Outlet } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import { getUser, requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
@@ -19,8 +18,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { GetEvent } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
eventId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing eventId" }, { status: 400 }));
|
||||
}
|
||||
|
||||
const { eventId } = parsed.data;
|
||||
|
||||
const event = await findEventRecord(eventId, authenticatedEnv.id);
|
||||
|
||||
if (!event) {
|
||||
return apiCors(request, json({ error: "Event not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(toJSON(event)));
|
||||
}
|
||||
|
||||
function toJSON(eventRecord: FoundEventRecord): GetEvent {
|
||||
return {
|
||||
id: eventRecord.eventId,
|
||||
name: eventRecord.name,
|
||||
createdAt: eventRecord.createdAt,
|
||||
updatedAt: eventRecord.updatedAt,
|
||||
runs: eventRecord.runs.map((run) => ({
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: run.completedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
type FoundEventRecord = NonNullable<Awaited<ReturnType<typeof findEventRecord>>>;
|
||||
|
||||
async function findEventRecord(eventId: string, environmentId: string) {
|
||||
return await prisma.eventRecord.findUnique({
|
||||
select: {
|
||||
eventId: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
runs: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
startedAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
eventId_environmentId: {
|
||||
eventId,
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { JobRunStatusRecordSchema } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
const RecordsSchema = z.array(JobRunStatusRecordSchema);
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
// Next authenticate the request
|
||||
const authenticationResult = await authenticateApiRequest(request, { allowPublicKey: true });
|
||||
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const { runId } = ParamsSchema.parse(params);
|
||||
|
||||
logger.debug("Get run statuses", {
|
||||
runId,
|
||||
});
|
||||
|
||||
try {
|
||||
const run = await prisma.jobRun.findUnique({
|
||||
where: {
|
||||
id: runId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
output: true,
|
||||
statuses: {
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return apiCors(request, json({ error: `No run found for id ${runId}` }, { status: 404 }));
|
||||
}
|
||||
|
||||
const parsedStatuses = RecordsSchema.parse(
|
||||
run.statuses.map((s) => ({
|
||||
...s,
|
||||
state: s.state ?? undefined,
|
||||
data: s.data ?? undefined,
|
||||
history: s.history ?? undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
return apiCors(
|
||||
request,
|
||||
json({
|
||||
run: {
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
output: run.output,
|
||||
},
|
||||
statuses: parsedStatuses,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return apiCors(request, json({ error: error.message }, { status: 400 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json({ error: "Something went wrong" }, { status: 500 }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRunPresenter } from "~/presenters/ApiRunPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import { taskListToTree } from "~/utils/taskListToTree";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
const SearchQuerySchema = z.object({
|
||||
cursor: z.string().optional(),
|
||||
take: z.coerce.number().default(20),
|
||||
subtasks: z.coerce.boolean().default(false),
|
||||
taskdetails: z.coerce.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
|
||||
}
|
||||
|
||||
const { runId } = parsed.data;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsedQuery = SearchQuerySchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
|
||||
if (!parsedQuery.success) {
|
||||
return apiCors(
|
||||
request,
|
||||
json({ error: "Invalid or missing query parameters" }, { status: 400 })
|
||||
);
|
||||
}
|
||||
|
||||
const query = parsedQuery.data;
|
||||
const showTaskDetails = query.taskdetails && authenticationResult.type === "PRIVATE";
|
||||
const take = Math.min(query.take, 50);
|
||||
|
||||
const presenter = new ApiRunPresenter();
|
||||
const jobRun = await presenter.call({
|
||||
runId: runId,
|
||||
maxTasks: take,
|
||||
taskDetails: showTaskDetails,
|
||||
subTasks: query.subtasks,
|
||||
cursor: query.cursor,
|
||||
});
|
||||
|
||||
if (!jobRun) {
|
||||
return apiCors(request, json({ message: "Run not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
if (jobRun.environmentId !== authenticatedEnv.id) {
|
||||
return apiCors(request, json({ message: "Run not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
const selectedTasks = jobRun.tasks.slice(0, take);
|
||||
|
||||
const tasks = taskListToTree(selectedTasks, query.subtasks);
|
||||
const nextTask = jobRun.tasks[take];
|
||||
|
||||
return apiCors(
|
||||
request,
|
||||
json({
|
||||
id: jobRun.id,
|
||||
status: jobRun.status,
|
||||
startedAt: jobRun.startedAt,
|
||||
updatedAt: jobRun.updatedAt,
|
||||
completedAt: jobRun.completedAt,
|
||||
output: jobRun.output,
|
||||
tasks: tasks.map((task) => {
|
||||
const { parentId, ...rest } = task;
|
||||
return { ...rest };
|
||||
}),
|
||||
statuses: jobRun.statuses.map((s) => ({
|
||||
...s,
|
||||
state: s.state ?? undefined,
|
||||
data: s.data ?? undefined,
|
||||
history: s.history ?? undefined,
|
||||
})),
|
||||
nextCursor: nextTask ? nextTask.id : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "~/db.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
OrganizationParamsSchema,
|
||||
organizationBillingPath,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectBackWithErrorMessage, redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { BillingService } from "~/services/billing.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { OrganizationParamsSchema, usagePath } from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findUnique({
|
||||
@@ -18,7 +18,7 @@ export async function loader({ request, params }: ActionFunctionArgs) {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,9 +6,10 @@ import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { ApiExample } from "~/services/externalApis/apis.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
await requireUser(request);
|
||||
await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const codeUrl = url.searchParams.get("url");
|
||||
invariant(typeof codeUrl === "string", "codeUrl is required");
|
||||
|
||||
+35
-11
@@ -1,6 +1,8 @@
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { DeleteEndpointService } from "~/services/endpoints/deleteEndpointService";
|
||||
import { IndexEndpointService } from "~/services/endpoints/indexEndpoint.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -8,21 +10,43 @@ const ParamsSchema = z.object({
|
||||
endpointParam: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ params }: ActionFunctionArgs) {
|
||||
const { endpointParam } = ParamsSchema.parse(params);
|
||||
const BodySchema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("refresh") }),
|
||||
z.object({ action: z.literal("delete") }),
|
||||
]);
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
if (request.method !== "POST") {
|
||||
throw new Response(null, { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new IndexEndpointService();
|
||||
await service.call(endpointParam, "MANUAL");
|
||||
const { endpointParam } = ParamsSchema.parse(params);
|
||||
const form = await request.formData();
|
||||
const formObject = Object.fromEntries(form.entries());
|
||||
const { action } = BodySchema.parse(formObject);
|
||||
|
||||
// Enqueue the endpoint to be probed in 10 seconds
|
||||
await workerQueue.enqueue(
|
||||
"probeEndpoint",
|
||||
{ id: endpointParam },
|
||||
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
|
||||
);
|
||||
switch (action) {
|
||||
case "refresh": {
|
||||
const service = new IndexEndpointService();
|
||||
await service.call(endpointParam, "MANUAL");
|
||||
|
||||
return json({ success: true });
|
||||
// Enqueue the endpoint to be probed in 10 seconds
|
||||
await workerQueue.enqueue(
|
||||
"probeEndpoint",
|
||||
{ id: endpointParam },
|
||||
{ jobKey: `probe:${endpointParam}`, runAt: new Date(Date.now() + 10000) }
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
case "delete": {
|
||||
const service = new DeleteEndpointService();
|
||||
await service.call(endpointParam, userId);
|
||||
return json({ success: true });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return json({ success: false, error: e }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActionFunction } from "@remix-run/node";
|
||||
import { ActionFunction, LoaderFunction, LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
@@ -14,7 +15,90 @@ const ParamSchema = z.object({
|
||||
jobId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { jobId } = ParamSchema.parse(params);
|
||||
|
||||
const job = await prisma.job.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
aliases: {
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
version: true,
|
||||
status: true,
|
||||
concurrencyLimit: true,
|
||||
concurrencyLimitGroup: {
|
||||
select: {
|
||||
name: true,
|
||||
concurrencyLimit: true,
|
||||
},
|
||||
},
|
||||
runs: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
status: true,
|
||||
},
|
||||
take: 1,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
select: {
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: jobId,
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const environments = job.aliases.map((alias) => ({
|
||||
type: alias.environment.type,
|
||||
enabled: alias.version.status === "ACTIVE",
|
||||
lastRun: alias.version.runs.at(0)?.createdAt,
|
||||
version: alias.version.version,
|
||||
concurrencyLimit: alias.version.concurrencyLimit,
|
||||
concurrencyLimitGroup: alias.version.concurrencyLimitGroup,
|
||||
}));
|
||||
|
||||
return typedjson({
|
||||
environments,
|
||||
});
|
||||
}
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
if (request.method.toUpperCase() !== "DELETE") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const { jobId } = ParamSchema.parse(params);
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
redirectBackWithErrorMessage,
|
||||
redirectWithErrorMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { ContinueRunService } from "~/services/runs/continueRun.server";
|
||||
import { ReRunService } from "~/services/runs/reRun.server";
|
||||
import { rootPath, runPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const schema = z.object({
|
||||
successRedirect: z.string(),
|
||||
failureRedirect: z.string(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
@@ -20,7 +26,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
return redirectWithErrorMessage(
|
||||
rootPath(),
|
||||
request,
|
||||
submission.error ? JSON.stringify(submission.error) : "Invalid form"
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -29,7 +39,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const run = await rerunService.call({ runId });
|
||||
|
||||
if (!run) {
|
||||
return redirectBackWithErrorMessage(request, "Unable to retry run");
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failureRedirect,
|
||||
request,
|
||||
"Unable to retry run"
|
||||
);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
@@ -48,6 +62,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return json({ errors: { body: error.message } }, { status: 400 });
|
||||
return redirectWithErrorMessage(
|
||||
submission.value.failureRedirect,
|
||||
request,
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { PlainClient, uiComponent } from "@team-plain/typescript-sdk";
|
||||
import { inspect } from "util";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import crypto from "node:crypto";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { rootPath } from "~/utils/pathBuilder";
|
||||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export const ParamsSchema = z.object({
|
||||
userId: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { userId, token } = ParamsSchema.parse(params);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
//check that the token is valid for the userId
|
||||
const hashedUserId = crypto
|
||||
.createHash("sha256")
|
||||
.update(`${userId}-${env.MAGIC_LINK_SECRET}`)
|
||||
.digest("hex");
|
||||
if (hashedUserId !== token) {
|
||||
return typedjson({
|
||||
success: false as const,
|
||||
message: "This unsubscribe link was invalid so we can't unsubscribe you.",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { marketingEmails: false },
|
||||
});
|
||||
|
||||
return typedjson({ success: true as const, email: user.email });
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : JSON.stringify(e);
|
||||
return typedjson({ success: false as const, message: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const result = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainCenteredContainer className="max-w-[22rem]">
|
||||
{result.success ? (
|
||||
<div>
|
||||
<FormTitle LeadingIcon="envelope" title="Unsubscribed" />
|
||||
<Paragraph spacing>
|
||||
You have unsubscribed from onboarding emails, {result.email}.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FormTitle LeadingIcon="envelope" title="Unsubscribe failed" />
|
||||
<Paragraph spacing>{result.message}</Paragraph>
|
||||
<Paragraph spacing>
|
||||
If you believe this is a bug, please{" "}
|
||||
<TextLink href="https://trigger.dev/contact">contact support</TextLink>.
|
||||
</Paragraph>
|
||||
<LinkButton variant="primary/medium" to={rootPath()}>
|
||||
Dashboard
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</MainCenteredContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -31,3 +31,9 @@ export async function setCurrentProjectId(id: string, request: Request) {
|
||||
session.set("currentProjectId", id);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function clearCurrentProjectId(request: Request) {
|
||||
const session = await getCurrentProjectSession(request);
|
||||
session.unset("currentProjectId");
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DateFormatter } from "@internationalized/date";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { BillingService } from "./billing.server";
|
||||
import { DeleteProjectService } from "./deleteProject.server";
|
||||
|
||||
export class DeleteOrganizationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
organizationSlug,
|
||||
userId,
|
||||
request,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
request: Request;
|
||||
}) {
|
||||
const organization = await this.#prismaClient.organization.findFirst({
|
||||
include: {
|
||||
projects: true,
|
||||
members: true,
|
||||
},
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId: userId } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
if (organization.deletedAt) {
|
||||
throw new Error("Organization already deleted");
|
||||
}
|
||||
|
||||
//check if they have an active subscription
|
||||
const { isManagedCloud } = featuresForRequest(request);
|
||||
const billingPresenter = new BillingService(isManagedCloud);
|
||||
const currentPlan = await billingPresenter.currentPlan(organization.id);
|
||||
|
||||
if (currentPlan && currentPlan.subscription && currentPlan.subscription.isPaying) {
|
||||
//they've cancelled and that date hasn't passed yet
|
||||
if (
|
||||
currentPlan.subscription.canceledAt &&
|
||||
new Date(currentPlan.subscription.canceledAt) > new Date()
|
||||
) {
|
||||
//a dateformatter that produces results like "Jan 1 2024"
|
||||
const dateFormatter = new DateFormatter("en-us", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
throw new Error(
|
||||
`This Organization has a canceled subscription. You can delete it when the cancelation date (${dateFormatter.format(
|
||||
new Date(currentPlan.subscription.canceledAt)
|
||||
)}) is in the past.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("You can't delete an Organization that has an active subscription");
|
||||
}
|
||||
|
||||
// loop through the projects and delete them
|
||||
const projectDeleteService = new DeleteProjectService();
|
||||
for (const project of organization.projects) {
|
||||
await projectDeleteService.call({ projectId: project.id, userId });
|
||||
}
|
||||
|
||||
//set all the integrations to disabled
|
||||
await this.#prismaClient.integrationConnection.updateMany({
|
||||
where: {
|
||||
organizationId: organization.id,
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
//mark the organization as deleted
|
||||
await this.#prismaClient.organization.update({
|
||||
where: {
|
||||
id: organization.id,
|
||||
},
|
||||
data: {
|
||||
runsEnabled: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { DeleteEndpointService } from "./endpoints/deleteEndpointService";
|
||||
import { logger } from "./logger.server";
|
||||
import { DisableScheduleSourceService } from "./schedules/disableScheduleSource.server";
|
||||
|
||||
type Options = ({ projectId: string } | { projectSlug: string }) & {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export class DeleteProjectService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(options: Options) {
|
||||
const projectId = await this.#getProjectId(options);
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
include: {
|
||||
environments: {
|
||||
include: {
|
||||
endpoints: true,
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
where: { deletedAt: null },
|
||||
include: {
|
||||
aliases: {
|
||||
where: {
|
||||
name: "latest",
|
||||
},
|
||||
include: {
|
||||
version: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
organization: true,
|
||||
},
|
||||
where: {
|
||||
id: projectId,
|
||||
organization: { members: { some: { userId: options.userId } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (project.deletedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
//disable and delete all jobs
|
||||
const service = new DisableScheduleSourceService();
|
||||
for (const environment of project.environments) {
|
||||
//disable the event dispatchers
|
||||
await this.#prismaClient.eventDispatcher.updateMany({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
},
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
const eventDispatchers = await this.#prismaClient.eventDispatcher.findMany({
|
||||
where: {
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Deleting jobs", { jobs: project.jobs });
|
||||
for (const job of project.jobs) {
|
||||
//disable all the job versions
|
||||
await this.#prismaClient.jobVersion.updateMany({
|
||||
where: {
|
||||
jobId: job.id,
|
||||
},
|
||||
data: {
|
||||
status: "DISABLED",
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.job.update({
|
||||
where: {
|
||||
id: job.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
//disable scheduled sources
|
||||
for (const eventDispatcher of eventDispatchers) {
|
||||
await service.call({
|
||||
key: job.id,
|
||||
dispatcher: eventDispatcher,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//delete all endpoints
|
||||
const deleteEndpointService = new DeleteEndpointService();
|
||||
for (const environment of project.environments) {
|
||||
for (const endpoint of environment.endpoints) {
|
||||
await deleteEndpointService.call(endpoint.id, options.userId);
|
||||
}
|
||||
}
|
||||
|
||||
//mark the project as deleted
|
||||
await this.#prismaClient.project.update({
|
||||
where: {
|
||||
id: project.id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #getProjectId(options: Options) {
|
||||
if ("projectId" in options) {
|
||||
return options.projectId;
|
||||
}
|
||||
|
||||
const { id } = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
slug: options.projectSlug,
|
||||
},
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
export class DeleteEndpointService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call(id: string, userId: string): Promise<void> {
|
||||
await this.#prismaClient.endpoint.update({
|
||||
data: {
|
||||
url: null,
|
||||
},
|
||||
where: {
|
||||
id,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,13 @@ export class PerformEndpointIndexService {
|
||||
|
||||
logger.debug("Performing endpoint index", endpointIndex);
|
||||
|
||||
if (!endpointIndex.endpoint.url) {
|
||||
logger.debug("Endpoint URL is not set", endpointIndex);
|
||||
return updateEndpointIndexWithError(this.#prismaClient, id, {
|
||||
message: "Endpoint URL is not set",
|
||||
});
|
||||
}
|
||||
|
||||
// Make a request to the endpoint to fetch a list of jobs
|
||||
const client = new EndpointApi(
|
||||
endpointIndex.endpoint.environment.apiKey,
|
||||
|
||||
@@ -29,6 +29,13 @@ export class ProbeEndpointService {
|
||||
id,
|
||||
});
|
||||
|
||||
if (!endpoint.url) {
|
||||
logger.debug(`Endpoint has no url`, {
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(endpoint.environment.apiKey, endpoint.url);
|
||||
|
||||
const { response, durationInMs } = await client.probe(MAX_RUN_CHUNK_EXECUTION_LIMIT);
|
||||
|
||||
@@ -16,6 +16,9 @@ export class RecurringEndpointIndexService {
|
||||
|
||||
const endpoints = await this.#prismaClient.endpoint.findMany({
|
||||
where: {
|
||||
url: {
|
||||
not: null,
|
||||
},
|
||||
environment: {
|
||||
type: {
|
||||
in: [RuntimeEnvironmentType.PRODUCTION, RuntimeEnvironmentType.STAGING],
|
||||
|
||||
@@ -577,6 +577,13 @@ export class IntegrationAuthRepository {
|
||||
throw new Error(`Connection ${connectionId} not found`);
|
||||
}
|
||||
|
||||
if (!connection.enabled) {
|
||||
logger.info("Connection is disabled", {
|
||||
connection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let customOAuthClient: OAuthClient | undefined;
|
||||
if (connection.integration.customClientReference) {
|
||||
const secretStore = getSecretStore(env.SECRET_STORE);
|
||||
@@ -687,9 +694,15 @@ export class IntegrationAuthRepository {
|
||||
if (connection.expiresAt) {
|
||||
const refreshBy = new Date(connection.expiresAt.getTime() - tokenRefreshThreshold * 1000);
|
||||
if (refreshBy < new Date()) {
|
||||
connection = await this.refreshConnection({
|
||||
const refreshedConnection = await this.refreshConnection({
|
||||
connectionId: connection.id,
|
||||
});
|
||||
|
||||
if (!refreshedConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
connection = refreshedConnection;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ export class HandleHttpEndpointService {
|
||||
);
|
||||
}
|
||||
|
||||
if (!httpEndpointEnvironment.endpoint.url) {
|
||||
logger.debug("Endpoint has no url", {
|
||||
httpEndpointId: httpEndpoint.id,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return json({ error: true, message: "Endpoint has no url" }, { status: 404 });
|
||||
}
|
||||
|
||||
const immediateResponseFilter = RequestFilterSchema.nullable().safeParse(
|
||||
httpEndpointEnvironment.immediateResponseFilter
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Job } from "@trigger.dev/database";
|
||||
import type { PrismaClient } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { telemetry } from "../telemetry.server";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class DeleteJobService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -25,6 +26,7 @@ export class DeleteJobService {
|
||||
const allDisabled = latestVersions.every((alias) => alias.version.status === "DISABLED");
|
||||
|
||||
if (!allDisabled) {
|
||||
logger.info("Not all latest versions are disabled, cannot delete job", { jobId: job.id });
|
||||
throw new Error("All latest versions must be disabled before deleting a job");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { $transaction, Prisma, PrismaClient, prisma } from "~/db.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
|
||||
const RESUMABLE_STATUSES = ["FAILURE", "TIMED_OUT", "UNRESOLVED_AUTH", "ABORTED", "CANCELED"];
|
||||
const RESUMABLE_STATUSES = [
|
||||
"FAILURE",
|
||||
"TIMED_OUT",
|
||||
"UNRESOLVED_AUTH",
|
||||
"ABORTED",
|
||||
"CANCELED",
|
||||
"INVALID_PAYLOAD",
|
||||
];
|
||||
|
||||
export class ContinueRunService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -37,6 +37,11 @@ export class CreateRunService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpoint.url) {
|
||||
logger.debug("Endpoint has no url", endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventRecord = await this.#prismaClient.eventRecord.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventId,
|
||||
|
||||
@@ -97,6 +97,10 @@ export class DeliverRunSubscriptionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subscription.run.endpoint.url === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const client = new EndpointApi(
|
||||
subscription.run.environment.apiKey,
|
||||
subscription.run.endpoint.url
|
||||
|
||||
@@ -135,6 +135,12 @@ export class PerformRunExecutionV3Service {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.endpoint.url) {
|
||||
return await this.#failRunExecution(this.#prismaClient, run, {
|
||||
message: `Endpoint has no URL set`,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new EndpointApi(run.environment.apiKey, run.endpoint.url);
|
||||
const event = eventRecordToApiJson(run.event);
|
||||
|
||||
@@ -201,6 +207,19 @@ export class PerformRunExecutionV3Service {
|
||||
|
||||
forceYieldCoordinator.deregisterRun(run.id);
|
||||
|
||||
//if the run has been canceled while it's being executed, we shouldn't do anything more
|
||||
const updatedRun = await this.#prismaClient.jobRun.findUnique({
|
||||
select: {
|
||||
status: true,
|
||||
},
|
||||
where: {
|
||||
id: run.id,
|
||||
},
|
||||
});
|
||||
if (!updatedRun || updatedRun.status === "CANCELED") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return await this.#failRunExecutionWithRetry(
|
||||
run,
|
||||
|
||||
@@ -103,11 +103,11 @@ function validateSchedule(schedule: ScheduleMetadata): ScheduleMetadata {
|
||||
}
|
||||
|
||||
function validateInterval(schedule: IntervalMetadata): ScheduleMetadata {
|
||||
if (schedule.options.seconds < 60) {
|
||||
if (schedule.options.seconds < 20) {
|
||||
return {
|
||||
type: "interval",
|
||||
options: {
|
||||
seconds: 60,
|
||||
seconds: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ export class DeliverHttpSourceRequestService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!httpSourceRequest.endpoint.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore(httpSourceRequest.source.secretReference.provider);
|
||||
|
||||
const secret = await secretStore.getSecret(
|
||||
|
||||
@@ -49,6 +49,10 @@ export class DeliverWebhookRequestService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requestDelivery.endpoint.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { secretReference } = requestDelivery.webhook.httpEndpoint;
|
||||
|
||||
const secretStore = getSecretStore(secretReference.provider);
|
||||
|
||||
@@ -35,6 +35,10 @@ export class InitializeTriggerService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpoint.url) {
|
||||
throw new Error("This environment's endpoint doesn't have a URL set");
|
||||
}
|
||||
|
||||
const dynamicTrigger = await this.#prismaClient.dynamicTrigger.findUniqueOrThrow({
|
||||
where: {
|
||||
endpointId_slug_type: {
|
||||
|
||||
@@ -131,6 +131,10 @@ export function organizationBillingPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/billing`;
|
||||
}
|
||||
|
||||
export function organizationSettingsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings`;
|
||||
}
|
||||
|
||||
export function usagePath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/billing`;
|
||||
}
|
||||
@@ -216,6 +220,10 @@ export function projectEventsPath(organization: OrgForPath, project: ProjectForP
|
||||
return `${projectPath(organization, project)}/events`;
|
||||
}
|
||||
|
||||
export function projectSettingsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${projectPath(organization, project)}/settings`;
|
||||
}
|
||||
|
||||
export function projectEventPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -64,7 +64,7 @@ client.defineJob({
|
||||
name: "Example Job",
|
||||
version: "0.1.0",
|
||||
trigger: eventTrigger({ name: "example.event" }),
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED === "true",
|
||||
enabled: process.env.TRIGGER_JOBS_DISABLED !== "true",
|
||||
run: async (payload, io, ctx) => {
|
||||
// your Job code here
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ description: "`intervalTrigger()` is set as a [Job's trigger](/sdk/job) to trigg
|
||||
Intervals are set with a number of seconds. There are some important considerations:
|
||||
|
||||
- The Job will first run the specified number of seconds after it has first connected to an [Environment](/documentation/concepts/environments-endpoints). This will happen when you first [deploy](/documentation/guides/deployment) that Job.
|
||||
- The minimum interval is 60 seconds (any input less than this it will default to 60).
|
||||
- The minimum interval is 20 seconds (any input less than this it will default to 20).
|
||||
- The maximum interval is 2_592_000 seconds (30 days), if you pass more than this it will trigger every 30 days.
|
||||
|
||||
If you wish to Run a Job at an exact time or less frequently than once pr day you should use a [cronTrigger()](/sdk/crontrigger) instead.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "logger"
|
||||
description: "Used to send log messages to the [Run log](/documentation/guides/viewing-runs)."
|
||||
description: "Used to send log messages to the [Run log](/docs/documentation/guides/viewing-runs)."
|
||||
---
|
||||
|
||||
There are 5 levels, that you can use to log messages.
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/airtable
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/airtable",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "Trigger.dev integration for airtable",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"airtable": "^0.12.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/github
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/github",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "The official GitHub integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -30,8 +30,8 @@
|
||||
"@octokit/request-error": "^5.0.1",
|
||||
"@octokit/webhooks": "^12.0.10",
|
||||
"octokit": "^3.1.2",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/linear
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/linear",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "Trigger.dev integration for @linear/sdk",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,8 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@linear/sdk": "^8.0.0",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# @trigger.dev/slack
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d69e4e71: Retry 400 status code, OpenAI returns this sometimes for no reason...
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/openai",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "The official OpenAI integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -42,8 +42,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"openai": "^4.16.1",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15"
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -60,11 +60,11 @@ function createTaskUsageProperties(
|
||||
},
|
||||
...("completion_tokens" in usage
|
||||
? [
|
||||
{
|
||||
label: "Completion Usage",
|
||||
text: String(usage.completion_tokens),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Completion Usage",
|
||||
text: String(usage.completion_tokens),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -83,35 +83,35 @@ function createTaskRateLimitProperties(headers: Headers | undefined) {
|
||||
return [
|
||||
...(remainingRequests
|
||||
? [
|
||||
{
|
||||
label: "Remaining Requests",
|
||||
text: remainingRequests ?? "Unknown",
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Remaining Requests",
|
||||
text: remainingRequests ?? "Unknown",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(resetRequests
|
||||
? [
|
||||
{
|
||||
label: "Reset Requests",
|
||||
text: resetRequests ?? "Unknown",
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Reset Requests",
|
||||
text: resetRequests ?? "Unknown",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(remainingTokens
|
||||
? [
|
||||
{
|
||||
label: "Remaining Tokens",
|
||||
text: remainingTokens ?? "Unknown",
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Remaining Tokens",
|
||||
text: remainingTokens ?? "Unknown",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(resetTokens
|
||||
? [
|
||||
{
|
||||
label: "Reset Tokens",
|
||||
text: resetTokens ?? "Unknown",
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Reset Tokens",
|
||||
text: resetTokens ?? "Unknown",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -128,6 +128,8 @@ export function handleOpenAIError(error: unknown) {
|
||||
}
|
||||
|
||||
return (
|
||||
//sometimes OpenAI returns a 400 that when retried becomes a 200…
|
||||
error.status === 400 ||
|
||||
error.status === 429 ||
|
||||
error.status === 408 ||
|
||||
error.status === 409 ||
|
||||
@@ -295,7 +297,7 @@ const requestOptionsKeys: KeysEnum<OpenAIRequestOptions> = {
|
||||
|
||||
export const isRequestOptions = (obj: unknown): obj is OpenAIRequestOptions => {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
!isEmptyObj(obj) &&
|
||||
Object.keys(obj).every((k) => hasOwn(requestOptionsKeys, k))
|
||||
@@ -310,4 +312,4 @@ function isEmptyObj(obj: Object | null | undefined): boolean {
|
||||
|
||||
function hasOwn(obj: Object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/plain
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/plain",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "The official Plain.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"@team-plain/typescript-sdk": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/replicate
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/replicate",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "Trigger.dev integration for replicate",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,8 +25,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"replicate": "^0.18.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# @trigger.dev/resend
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- da90ee13: Fix resend integration example comment
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/resend",
|
||||
"version": "2.3.15",
|
||||
"version": "2.3.18",
|
||||
"description": "The official Resend.com integration for Trigger.dev",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -24,8 +24,8 @@
|
||||
"build:tsup": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.15",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.15",
|
||||
"@trigger.dev/integration-kit": "workspace:^2.3.18",
|
||||
"@trigger.dev/sdk": "workspace:^2.3.18",
|
||||
"resend": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -165,7 +165,7 @@ export class Resend implements TriggerIntegration {
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await io.resend.audiences.create("📧", {
|
||||
* name: payload.name,
|
||||
* name: payload.name
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -182,8 +182,8 @@ export class Resend implements TriggerIntegration {
|
||||
* email: payload.email,
|
||||
* first_name: payload.first_name,
|
||||
* last_name: payload.last_name,
|
||||
* unsubscribed: false
|
||||
* audienceId: payload.audienceId
|
||||
* unsubscribed: payload.unsubscribed,
|
||||
* audience_id: payload.audience_id
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# @trigger.dev/sendgrid
|
||||
|
||||
## 2.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.18
|
||||
- @trigger.dev/sdk@2.3.18
|
||||
|
||||
## 2.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [dd879c8e]
|
||||
- @trigger.dev/sdk@2.3.17
|
||||
- @trigger.dev/integration-kit@2.3.17
|
||||
|
||||
## 2.3.16
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/integration-kit@2.3.16
|
||||
- @trigger.dev/sdk@2.3.16
|
||||
|
||||
## 2.3.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user